UNPKG

codeceptjs

Version:

Modern Era Acceptance Testing Framework for NodeJS

1,745 lines (1,563 loc) 70.9 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; } /** * {{> amOnPage }} */ 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(); } /** * {{> resizeWindow }} * * 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); } /** * {{> moveCursorTo }} * {{ 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(); } /** * {{> dragAndDrop }} */ async dragAndDrop(srcElement, destElement) { return proceedDragAndDrop.call(this, srcElement, destElement); } /** * {{> refreshPage }} */ async refreshPage() { return this.page.reload({ timeout: this.options.getPageTimeout, waitUntil: this.options.waitForNavigation }); } /** * {{> scrollPageToTop }} */ scrollPageToTop() { return this.executeScript(() => { window.scrollTo(0, 0); }); } /** * {{> 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, )); }); } /** * {{> scrollTo }} */ 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(); } /** * {{> seeInTitle }} */ async seeInTitle(text) { const title = await this.page.title(); stringIncludes('web page title').assert(text, title); } /** * {{> grabPageScrollPosition }} */ 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); } /** * {{> dontSeeInTitle }} */ async dontSeeInTitle(text) { const title = await this.page.title(); stringIncludes('web page title').negate(text, title); } /** * {{> grabTitle }} */ 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(); } /** * {{> grabNumberOfOpenTabs }} */ async grabNumberOfOpenTabs() { const pages = await this.browser.pages(); return pages.length; } /** * {{> seeElement }} * {{ 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')); } /** * {{> dontSeeElement }} * {{ 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')); } /** * {{> seeElementInDOM }} */ async seeElementInDOM(locator) { const els = await this._locate(locator); return empty('elements on page').negate(els.filter(v => v).fill('ELEMENT')); } /** * {{> dontSeeElementInDOM }} */ async dontSeeElementInDOM(locator) { const els = await this._locate(locator); return empty('elements on a page').assert(els.filter(v => v).fill('ELEMENT')); } /** * {{> click }} * * {{ react }} */ async click(locator, context = null) { return proceedClick.call(this, locator, context); } /** * {{> clickLink }} * * {{ 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}`); } } /** * {{> doubleClick }} * * {{ react }} */ async doubleClick(locator, context = null) { return proceedClick.call(this, locator, context, { clickCount: 2 }); } /** * {{> rightClick }} * * {{ react }} */ async rightClick(locator, context = null) { return proceedClick.call(this, locator, context, { button: 'right' }); } /** * {{> checkOption }} */ 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(); } } /** * {{> uncheckOption }} */ 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(); } } /** * {{> seeCheckboxIsChecked }} */ async seeCheckboxIsChecked(field) { return proceedIsChecked.call(this, 'assert', field); } /** * {{> dontSeeCheckboxIsChecked }} */ async dontSeeCheckboxIsChecked(field) { return proceedIsChecked.call(this, 'negate', field); } /** * {{> pressKeyDown }} */ async pressKeyDown(key) { key = getNormalizedKey.call(this, key); await this.page.keyboard.down(key); return this._waitForAction(); } /** * {{> pressKeyUp }} */ async pressKeyUp(key) { key = getNormalizedKey.call(this, key); await this.page.keyboard.up(key); return this._waitForAction(); } /** * {{> pressKeyWithKeyNormalization }} * * _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(); } /** * {{> fillField }} * {{ 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(); } /** * {{> clearField }} */ async clearField(field) { return this.fillField(field, ''); } /** * {{> appendField }} * * {{ 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(); } /** * {{> seeInField }} */ async seeInField(field, value) { return proceedSeeInField.call(this, 'assert', field, value); } /** * {{> dontSeeInField }} */ async dontSeeInField(field, value) { return proceedSeeInField.call(this, 'negate', field, value); } /** * {{> attachFile }} */ 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(); } /** * {{> selectOption }} */ 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(); } /** * {{> grabNumberOfVisibleElements }} * {{ 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; } /** * {{> seeInCurrentUrl }} */ async seeInCurrentUrl(url) { stringIncludes('url').assert(url, await this._getPageUrl()); } /** * {{> dontSeeInCurrentUrl }} */ async dontSeeInCurrentUrl(url) { stringIncludes('url').negate(url, await this._getPageUrl()); } /** * {{> seeCurrentUrlEquals }} */ async seeCurrentUrlEquals(url) { urlEquals(this.options.url).assert(url, await this._getPageUrl()); } /** * {{> dontSeeCurrentUrlEquals }} */ async dontSeeCurrentUrlEquals(url) { urlEquals(this.options.url).negate(url, await this._getPageUrl()); } /** * {{> see }} * * {{ 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); } /** * {{> dontSee }} * * {{ react }} */ async dontSee(text, context = null) { return proceedSee.call(this, 'negate', text, context); } /** * {{> grabSource }} */ 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; consoleLogStore.clear(); return logs; } /** * {{> grabCurrentUrl }} */ async grabCurrentUrl() { return this._getPageUrl(); } /** * {{> seeInSource }} */ async seeInSource(text) { const source = await this.page.content(); stringIncludes('HTML source of a page').assert(text, source); } /** * {{> dontSeeInSource }} */ async dontSeeInSource(text) { const source = await this.page.content(); stringIncludes('HTML source of a page').negate(text, source); } /** * {{> seeNumberOfElements }} * * {{ react }} */ async seeNumberOfElements(locator, num) { const elements = await this._locate(locator); return equals(`expected number of elements (${locator}) is ${num}, but found ${elements.length}`).assert(elements.length, num); } /** * {{> seeNumberOfVisibleElements }} * * {{ react }} */ async seeNumberOfVisibleElements(locator, num) { const res = await this.grabNumberOfVisibleElements(locator); return equals(`expected number of visible elements (${locator}) is ${num}, but found ${res}`).assert(res, num); } /** * {{> setCookie }} */ async setCookie(cookie) { if (Array.isArray(cookie)) { return this.page.setCookie(...cookie); } return this.page.setCookie(cookie); } /** * {{> seeCookie }} * */ async seeCookie(name) { const cookies = await this.page.cookies(); empty(`cookie ${name} to be set`).negate(cookies.filter(c => c.name === name)); } /** * {{> dontSeeCookie }} */ async dontSeeCookie(name) { const cookies = await this.page.cookies(); empty(`cookie ${name} to be set`).assert(cookies.filter(c => c.name === name)); } /** * {{> grabCookie }} * * Returns cookie in JSON format. If name not passed returns all cookies for this domain. */ async grabCookie(name) { const cookies = await this.page.cookies(); if (!name) return cookies; const cookie = cookies.filter(c => c.name === name); if (cookie[0]) return cookie[0]; } /** * {{> clearCookie }} */ async clearCookie(name) { const cookies = await this.page.cookies(); if (!name) { return this.page.deleteCookie.apply(this.page, cookies); } const cookie = cookies.filter(c => c.name === name); if (!cookie[0]) return; return this.page.deleteCookie(cookie[0]); } /** * {{> executeScript }} * * If a function returns a Promise It will wait for it resolution. */ async executeScript(fn) { let context = this.page; if (this.context && this.context.constructor.name === 'Frame') { context = this.context; // switching to iframe context } return context.evaluate.apply(context, arguments); } /** * {{> executeAsyncScript }} * * Asynchronous scripts can also be executed with `executeScript` if a function returns a Promise. */ async executeAsyncScript(fn) { const args = Array.from(arguments); const asyncFn = function () { const args = Array.from(arguments); const fn = eval(`(${args.shift()})`); // eslint-disable-line no-eval return new Promise((done) => { args.push(done); fn.apply(null, args); }); }; args[0] = args[0].toString(); args.unshift(asyncFn); return this.page.evaluate.apply(this.page, args); } /** * {{> grabTextFrom }} * {{ react }} */ async grabTextFrom(locator) { const els = await this._locate(locator); assertElementExists(els, locator); const texts = []; for (const el of els) { texts.push(await (await el.getProperty('innerText')).jsonValue()); } if (texts.length === 1) return texts[0]; return texts; } /** * {{> grabValueFrom }} */ async grabValueFrom(locator) { const els = await findFields.call(this, locator); assertElementExists(els, locator); return els[0].getProperty('value').then(t => t.jsonValue()); } /** * {{> grabHTMLFrom }} */ async grabHTMLFrom(locator) { const els = await this._locate(locator); assertElementExists(els, locator); const values = await Promise.all(els.map(el => el.executionContext().evaluate(element => element.innerHTML, el))); if (Array.isArray(values) && values.length === 1) { return values[0]; } return values; } /** * {{> grabCssPropertyFrom }} * {{ react }} */ async grabCssPropertyFrom(locator, cssProperty) { const els = await this._locate(locator); const res = await Promise.all(els.map(el => el.executionContext().evaluate(el => JSON.parse(JSON.stringify(getComputedStyle(el))), el))); const cssValues = res.map(props => props[cssProperty]); if (res.length > 0) { return cssValues; } return cssValues[0]; } /** * {{> seeCssPropertiesOnElements }} * {{ react }} */ async seeCssPropertiesOnElements(locator, cssProperties) { const res = await this._locate(locator); assertElementExists(res, locator); const cssPropertiesCamelCase = convertCssPropertiesToCamelCase(cssProperties); const elemAmount = res.length; const commands = []; res.forEach((el) => { Object.keys(cssPropertiesCamelCase).forEach((prop) => { commands.push(el.executionContext() .evaluate((el) => { const style = window.getComputedStyle ? getComputedStyle(el) : el.currentStyle; return JSON.parse(JSON.stringify(style)); }, el) .then((props) => { if (isColorProperty(prop)) { return convertColorToRGBA(props[prop]); } return props[prop]; })); }); }); let props = await Promise.all(commands); const values = Object.keys(cssPropertiesCamelCase).map(key => cssPropertiesCamelCase[key]); if (!Array.isArray(props)) props = [props]; let chunked = chunkArray(props, values.length); chunked = chunked.filter((val) => { for (let i = 0; i < val.length; ++i) { if (val[i] !== values[i]) return false; } return true; }); return equals(`all elements (${locator}) to have CSS property ${JSON.stringify(cssProperties)}`).assert(chunked.length, elemAmount); } /** * {{> seeAttributesOnElements }} * {{ react }} */ async seeAttributesOnElements(locator, attributes) { const res = await this._locate(locator); assertElementExists(res, locator); const elemAmount = res.length; const commands = []; res.forEach((el) => { Object.keys(attributes).forEach((prop) => { commands.push(el .executionContext() .evaluateHandle((el, attr) => el[attr] || el.getAttribute(attr), el, prop) .then(el => el.jsonValue())); }); }); let attrs = await Promise.all(commands); const values = Object.keys(attributes).map(key => attributes[key]); if (!Array.isArray(attrs)) attrs = [attrs]; let chunked = chunkArray(attrs, values.length); chunked = chunked.filter((val) => { for (let i = 0; i < val.length; ++i) { if (val[i] !== values[i]) return false; } return true; }); return equals(`all elements (${locator}) to have attributes ${JSON.stringify(attributes)}`).assert(chunked.length, elemAmount); } /** * {{> dragSlider }} * {{ react }} */ async dragSlider(locator, offsetX = 0) { const src = await this._locate(locator); assertElementExists(src, locator, 'Slider Element'); // Note: Using private api ._clickablePoint because the .BoundingBox does not take into account iframe offsets! const sliderSource = await src[0]._clickablePoint(); // Drag start point await this.page.mouse.move(sliderSource.x, sliderSource.y, { steps: 5 }); await this.page.mouse.down(); // Drag destination await this.page.mouse.move(sliderSource.x + offsetX, sliderSource.y, { steps: 5 }); await this.page.mouse.up(); await this._waitForAction(); } /** * {{> grabAttributeFrom }} * {{ react }} */ async grabAttributeFrom(locator, attr) { const els = await this._locate(locator); assertElementExists(els, locator); const array = []; for (let index = 0; index < els.length; index++) { const a = await this._evaluateHandeInContext((el, attr) => el[attr] || el.getAttribute(attr), els[index], attr); array.push(await a.jsonValue()); } return array.length === 1 ? array[0] : array; } /** * {{> saveScreenshot }} */ async saveScreenshot(fileName, fullPage) { const fullPageOption = fullPage || this.options.fullPageScreenshots; const outputFile = screenshotOutputFolder(fileName); this.debug(`Screenshot is saving to ${outputFile}`); const openSessions = await this.browser.pages(); if (openSessions.length > 1) { this.page = await this.browser.targets()[this.browser.targets().length - 1].page(); } return this.page.screenshot({ path: outputFile, fullPage: fullPageOption, type: 'png' }); } async _failed(test) { await this._withinEnd(); } /** * {{> wait }} */ async wait(sec) { return new Promise(((done) => { setTimeout(done, sec * 1000); })); } /** * {{> waitForEnabled }} */ async waitForEnabled(locator, sec) { const waitTimeout = sec ? sec * 1000 : this.options.waitForTimeout; locator = new Locator(locator, 'css'); const matcher = await this.context; let waiter; const context = await this._getContext(); if (locator.isCSS()) { const enabledFn = function (locator) { const els = document.querySelectorAll(locator); if (!els || els.length === 0) { return false; } return Array.prototype.filter.call(els, el => !el.disabled).length > 0; }; waiter = context.waitForFunction(enabledFn, { timeout: waitTimeout }, locator.value); } else { const enabledFn = function (locator, $XPath) { eval($XPath); // eslint-disable-line no-eval return $XPath(null, locator).filter(el => !el.disabled).length > 0; }; waiter = context.waitForFunction(enabledFn, { timeout: waitTimeout }, locator.value, $XPath.toString()); } return waiter.catch((err) => { throw new Error(`element (${locator.toString()}) still not enabled after ${waitTimeout / 1000} sec\n${err.message}`); }); } /** * {{> waitForValue }} */ async waitForValue(field, value, sec) { const waitTimeout = sec ? sec * 1000 : this.options.waitForTimeout; const locator = new Locator(field, 'css'); const matcher = await this.context; let waiter; const context = await this._getContext(); if (locator.isCSS()) { const valueFn = function (locator, value) { const els = document.querySelectorAll(locator); if (!els || els.length === 0) { return false; } return Array.prototype.filter.call(els, el => (el.value || '').indexOf(value) !== -1).length > 0; }; waiter = context.waitForFunction(valueFn, { timeout: waitTimeout }, locator.value, value); } else { const valueFn = function (locator, $XPath, value) { eval($XPath); // eslint-disable-line no-eval return $XPath(null, locator).filter(el => (el.value || '').indexOf(value) !== -1).length > 0; }; waiter = context.waitForFunction(valueFn, { timeout: waitTimeout }, locator.value, $XPath.toString(), value); } return waiter.catch((err) => { const loc = locator.toString(); throw new Error(`element (${loc}) is not in DOM or there is no element(${loc}) with value "${value}" after ${waitTimeout / 1000} sec\n${err.message}`); }); } /** * {{> waitNumberOfVisibleElements }} * {{ react }} */ async waitNumberOfVisibleElements(locator, num, sec) { const waitTimeout = sec ? sec * 1000 : this.options.waitForTimeout; locator = new Locator(locator, 'css'); const matcher = await this.context; let waiter; const context = await this._getContext(); if (locator.isCSS()) { const visibleFn = function (locator, num) { const els = document.querySelectorAll(locator); if (!els || els.length === 0) { return false; } return Array.prototype.filter.call(els, el => el.offsetParent !== null).length === num; }; waiter = context.waitForFunction(visibleFn, { timeout: waitTimeout }, locator.value, num); } else { const visibleFn = function (locator, $XPath, num) { eval($XPath); // eslint-disable-line no-eval return $XPath(null, locator).filter(el => el.offsetParent !== null).length === num; }; waiter = context.waitForFunction(visibleFn, { timeout: waitTimeout }, locator.value, $XPath.toString(), num); } return waiter.catch((err) => { throw new Error(`The number of elements (${locator.toString()}) is not ${num} after ${waitTimeout / 1000} sec\n${err.message}`); }); } /** * {{> waitForClickable }} */ async waitForClickable(locator, waitTimeout) { const els = await this._locate(locator); assertElementExists(els, locator); return this.waitForFunction(isElementClickable, [els[0]], waitTimeout).catch(async (e) => { if (/failed: timeout/i.test(e.message)) { throw new Error(`element ${new Locator(locator).toString()} still not clickable after ${waitTimeout || this.options.waitForTimeout / 1000} sec`); } else { throw e; } }); } /** * {{> waitForElement }} * {{ react }} */ async waitForElement(locator, sec) { const waitTimeout = sec ? sec * 1000 : this.options.waitForTimeout; locator = new Locator(locator, 'css'); let waiter; const context = await this._getContext(); if (locator.isCSS()) { waiter = context.waitForSelector(locator.simplify(), { timeout: waitTimeout }); } else { waiter = context.waitForXPath(locator.value, { timeout: waitTimeout }); } return waiter.catch((err) => { throw new Error(`element (${locator.toString()}) still not present on page after ${waitTimeout / 1000} sec\n${err.message}`); }); } /** * {{> waitForVisible }} * * This method accepts [React selectors](https://codecept.io/react). */ async waitForVisible(locator, sec) { const waitTimeout = sec ? sec * 1000 : this.options.waitForTimeout;