UNPKG

codeceptjs

Version:

Supercharged End 2 End Testing Framework for NodeJS

1,780 lines (1,597 loc) 91.2 kB
const axios = require('axios') const fs = require('fs') const fsExtra = require('fs-extra') const path = require('path') const Helper = require('@codeceptjs/helper') const { v4: uuidv4 } = require('uuid') const promiseRetry = require('promise-retry') const Locator = require('../locator') const recorder = require('../recorder') const store = require('../store') 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, toCamelCase, clearString, convertCssPropertiesToCamelCase, screenshotOutputFolder, getNormalizedKeyAttributeValue, isModifierKey, requireWithFallback, normalizeSpacesInString, } = require('../utils') const { isColorProperty, convertColorToRGBA } = require('../colorUtils') const ElementNotFound = require('./errors/ElementNotFound') const RemoteBrowserConnectionRefused = require('./errors/RemoteBrowserConnectionRefused') const Popup = require('./extras/Popup') const Console = require('./extras/Console') const { highlightElement } = require('./scripts/highlightElement') const { blurElement } = require('./scripts/blurElement') const { dontSeeElementError, seeElementError, dontSeeElementInDOMError, seeElementInDOMError } = require('./errors/ElementAssertion') const { dontSeeTraffic, seeTraffic, grabRecordedNetworkTraffics, stopRecordingTraffic, flushNetworkTraffics } = require('./network/actions') let puppeteer let perfTiming const popupStore = new Popup() const consoleLogStore = new Console() /** * ## Configuration * * This helper should be configured in codecept.conf.js * * @typedef PuppeteerConfig * @type {object} * @prop {string} url - base url of website to be tested * @prop {object} [basicAuth] (optional) the basic authentication to pass to base url. Example: {username: 'username', password: 'password'} * @prop {boolean} [show] - show Google Chrome window for debug. * @prop {boolean} [restart=true] - restart browser between tests. * @prop {boolean} [disableScreenshots=false] - don't save screenshot on failure. * @prop {boolean} [fullPageScreenshots=false] - make full page screenshots on failure. * @prop {boolean} [uniqueScreenshotNames=false] - option to prevent screenshot override if you have scenarios with the same name in different suites. * @prop {boolean} [trace=false] - record [tracing information](https://pptr.dev/api/puppeteer.tracing) with screenshots. * @prop {boolean} [keepTraceForPassedTests=false] - save trace for passed tests. * @prop {boolean} [keepBrowserState=false] - keep browser state between tests when `restart` is set to false. * @prop {boolean} [keepCookies=false] - keep cookies between tests when `restart` is set to false. * @prop {number} [waitForAction=100] - how long to wait after click, doubleClick or PressKey actions in ms. Default: 100. * @prop {string} [waitForNavigation=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. * @prop {number} [pressKeyDelay=10] - delay between key presses in ms. Used when calling Puppeteers page.type(...) in fillField/appendField * @prop {number} [getPageTimeout=30000] - config option to set maximum navigation time in milliseconds. If the timeout is set to 0, then timeout will be disabled. * @prop {number} [waitForTimeout=1000] - default wait* timeout in ms. * @prop {string} [windowSize] - default window size. Set a dimension in format WIDTHxHEIGHT like `640x480`. * @prop {string} [userAgent] - user-agent string. * @prop {boolean} [manualStart=false] - do not start browser before a test, start it manually inside a helper with `this.helpers["Puppeteer"]._startBrowser()`. * @prop {string} [browser=chrome] - can be changed to `firefox` when using [puppeteer-firefox](https://codecept.io/helpers/Puppeteer-firefox). * @prop {object} [chrome] - pass additional [Puppeteer run options](https://github.com/GoogleChrome/puppeteer/blob/master/docs/api.md#puppeteerlaunchoptions). * @prop {boolean} [highlightElement] - highlight the interacting elements. Default: false. Note: only activate under verbose mode (--verbose). */ const config = {} /** * 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` or `puppeteer-core` package to be installed. * ``` * npm i puppeteer --save * ``` * or * ``` * npm i puppeteer-core --save * ``` * Using `puppeteer-core` package, will prevent the download of browser binaries and allow connecting to an existing browser installation or for connecting to a remote one. * * > Experimental Firefox support [can be activated](https://codecept.io/helpers/Puppeteer-firefox). * * <!-- configuration --> * * #### Trace Recording Customization * * Trace recording provides complete information on test execution and includes 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 * * #### 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. * * #### Example #5: Target URL with provided basic authentication * * ```js * { * helpers: { * Puppeteer : { * url: 'http://localhost', * basicAuth: {username: 'username', password: 'password'}, * show: true * } * } * } * ``` * #### Troubleshooting * * Error Message: `No usable sandbox!` * * When running Puppeteer on CI try to disable sandbox if you see that message * * ``` * helpers: { * Puppeteer: { * url: 'http://localhost', * show: false, * chrome: { * args: ['--no-sandbox', '--disable-setuid-sandbox'] * } * }, * } * ``` * * * * ## 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 = requireWithFallback('puppeteer', 'puppeteer-core') // set defaults this.isRemoteBrowser = false this.isRunning = false this.isAuthenticated = false this.sessionPages = {} this.activeSessionName = '' // 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 = { browser: 'chrome', waitForAction: 100, waitForTimeout: 1000, pressKeyDelay: 10, fullPageScreenshots: false, disableScreenshots: false, uniqueScreenshotNames: false, manualStart: false, getPageTimeout: 30000, waitForNavigation: 'load', restart: true, keepCookies: false, keepBrowserState: false, show: false, defaultPopupAction: 'accept', highlightElement: false, } return Object.assign(defaults, config) } _getOptions(config) { return config.browser === 'firefox' ? Object.assign(this.options.firefox, { product: 'firefox' }) : this.options.chrome } _setConfig(config) { this.options = this._validateConfig(config) this.puppeteerOptions = { headless: !this.options.show, ...this._getOptions(config), } if (this.puppeteerOptions.headless) this.puppeteerOptions.headless = 'new' 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', }, { name: 'windowSize', message: 'Browser viewport size', default: '1200x900', }, ] } static _checkRequirements() { try { requireWithFallback('puppeteer', 'puppeteer-core') } catch (e) { return ['puppeteer'] } } _init() {} _beforeSuite() { if (!this.options.restart && !this.options.manualStart && !this.isRunning) { this.debugSection('Session', 'Starting singleton browser session') return this._startBrowser() } } async _before(test) { this.sessionPages = {} this.currentRunningTest = test recorder.retry({ retries: test?.opts?.conditionalRetries || 3, when: err => { if (!err || typeof err.message !== 'string') { return false } // ignore context errors return err.message.includes('context') }, }) 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() const defaultCtx = contexts.shift() await Promise.all(contexts.map(c => c.close())) if (this.options.restart) { this.isRunning = false return this._stopBrowser() } // ensure this.page is from default context if (this.page) { const existingPages = defaultCtx.targets().filter(t => t.type() === 'page') await this._setPage(await existingPages[0].page()) } 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.executeScript('sessionStorage.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 (name = '') => { this.debugSection('Incognito Tab', 'opened') this.activeSessionName = name const bc = await this.browser.createBrowserContext() await bc.newPage() // Create a new page inside context. return bc }, stop: async () => { // is closed by _after }, loadVars: async context => { const existingPages = context.targets().filter(t => t.type() === 'page') this.sessionPages[this.activeSessionName] = await existingPages[0].page() return this._setPage(this.sessionPages[this.activeSessionName]) }, restoreVars: async session => { this.withinLocator = null if (!session) { this.activeSessionName = '' } else { this.activeSessionName = session } const defaultCtx = this.browser.defaultBrowserContext() const existingPages = defaultCtx.targets().filter(t => t.type() === 'page') await this._setPage(await existingPages[0].page()) return this._waitForAction() }, } } /** * Use Puppeteer 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/puppeteer/puppeteer/blob/master/docs/api.md#class-page), [`browser`](https://github.com/puppeteer/puppeteer/blob/master/docs/api.md#class-browser) } from Puppeteer API are available. * * ```js * I.usePuppeteerTo('emulate offline mode', async ({ page }) { * await page.setOfflineMode(true); * }); * ``` * * @param {string} description used to show in logs. * @param {function} fn async function that is executed with Puppeteer as argument */ usePuppeteerTo(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') } /** * {{> seeInPopup }} */ 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._addErrorListener(page) this.page = page if (!page) return page.setDefaultNavigationTimeout(this.options.getPageTimeout) this.context = await this.page.$('body') if (this.options.browser === 'chrome') { await page.bringToFront() } } async _addErrorListener(page) { if (!page) { return } page.on('error', async error => { console.error('Puppeteer page error', error) }) } /** * 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)) .catch(e => { console.error('Puppeteer page error', e) }), ) 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() this.isAuthenticated = false await this.browser.close() if (this.isRemoteBrowser) { await this.browser.disconnect() } } 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)) { return this.switchTo(null).then(() => frame.reduce((p, frameLocator) => p.then(() => this.switchTo(frameLocator)), Promise.resolve())) } await this.switchTo(frame) this.withinLocator = new Locator(frame) 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 } if (this.options.basicAuth && this.isAuthenticated !== true) { if (url.includes(this.options.url)) { await this.page.authenticate(this.options.basicAuth) this.isAuthenticated = true } } if (this.options.trace) { const fileName = `${`${global.output_dir}${path.sep}trace${path.sep}${uuidv4()}_${clearString(this.currentRunningTest.title)}`.slice(0, 245)}.json` const dir = path.dirname(fileName) if (!fileExists(dir)) fs.mkdirSync(dir) await this.page.tracing.start({ screenshots: true, path: fileName }) this.currentRunningTest.artifacts.trace = fileName } 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 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. * * {{> resizeWindow }} * */ 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.setPuppeteerRequestHeaders({ * 'X-Sent-By': 'CodeceptJS', * }); * ``` * * @param {object} customHeaders headers to set */ async setPuppeteerRequestHeaders(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, locator) // Use manual mouse.move instead of .hover() so the offset can be added to the coordinates const { x, y } = await getClickablePoint(els[0]) await this.page.mouse.move(x + offsetX, y + offsetY) return this._waitForAction() } /** * {{> focus }} * */ async focus(locator) { const els = await this._locate(locator) assertElementExists(els, locator, 'Element to focus') const el = els[0] await el.click() await el.focus() return this._waitForAction() } /** * {{> blur }} * */ async blur(locator) { const els = await this._locate(locator) assertElementExists(els, locator, 'Element to blur') await blurElement(els[0], this.page) 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') const el = els[0] await el.evaluate(el => el.scrollIntoView()) const elementCoordinates = await getClickablePoint(els[0]) 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() { function getScrollPosition() { return { x: window.pageXOffset, y: window.pageYOffset, } } return this.executeScript(getScrollPosition) } /** * {{> seeTitleEquals }} */ 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) { const context = await this.context return findElements.call(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.context 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) } /** * {{> grabWebElements }} * */ async grabWebElements(locator) { return this._locate(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() && el))).filter(v => v) // Puppeteer visibility was ignored? | Remove when Puppeteer is fixed els = await Promise.all(els.map(async el => (await el.evaluate(node => window.getComputedStyle(node).visibility !== 'hidden' && window.getComputedStyle(node).display !== 'none')) && el)) try { return empty('visible elements').negate(els.filter(v => v).fill('ELEMENT')) } catch (e) { dontSeeElementError(locator) } } /** * {{> dontSeeElement }} * {{ react }} */ async dontSeeElement(locator) { let els = await this._locate(locator) els = (await Promise.all(els.map(el => el.boundingBox() && el))).filter(v => v) // Puppeteer visibility was ignored? | Remove when Puppeteer is fixed els = await Promise.all(els.map(async el => (await el.evaluate(node => window.getComputedStyle(node).visibility !== 'hidden' && window.getComputedStyle(node).display !== 'none')) && el)) try { return empty('visible elements').assert(els.filter(v => v).fill('ELEMENT')) } catch (e) { seeElementError(locator) } } /** * {{> seeElementInDOM }} */ 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) } } /** * {{> dontSeeElementInDOM }} */ 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) } } /** * {{> click }} * * {{ react }} */ async click(locator, context = null) { return proceedClick.call(this, locator, context) } /** * {{> forceClick }} * * {{ react }} */ async forceClick(locator, context = null) { let matcher = await this.context if (context) { const els = await this._locate(context) assertElementExists(els, context) matcher = els[0] } const els = await findClickable.call(this, matcher, locator) if (context) { assertElementExists(els, locator, 'Clickable element', `was not found inside element ${new Locator(context).toString()}`) } else { assertElementExists(els, locator, 'Clickable element') } const elem = els[0] return this.executeScript(el => { if (document.activeElement instanceof HTMLElement) { document.activeElement.blur() } const event = document.createEvent('MouseEvent') event.initEvent('click', true, true) return el.dispatchEvent(event) }, elem) } /** * {{> 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) try { return this.page._client.send('Page.setDownloadBehavior', { behavior: 'allow', downloadPath }) } catch (e) { 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() } /** * _Note:_ Shortcuts like `'Meta'` + `'A'` do not work on macOS ([GoogleChrome/puppeteer#1313](https://github.com/GoogleChrome/puppeteer/issues/1313)). * * {{> pressKeyWithKeyNormalization }} */ 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() } /** * {{> type }} */ 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) } } /** * {{> fillField }} * {{ react }} */ async fillField(field, value) { const els = await findVisibleFields.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) } highlightActiveElement.call(this, el, await this._getContext()) 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 findVisibleFields.call(this, field) assertElementExists(els, field, 'Field') highlightActiveElement.call(this, els[0], await this._getContext()) await els[0].press('End') await els[0].type(value.toString(), { delay: this.options.pressKeyDelay }) return this._waitForAction() } /** * {{> seeInField }} */ async seeInField(field, value) { const _value = typeof value === 'boolean' ? value : value.toString() return proceedSeeInField.call(this, 'assert', field, _value) } /** * {{> dontSeeInField }} */ async dontSeeInField(field, value) { const _value = typeof value === 'boolean' ? value : value.toString() return proceedSeeInField.call(this, 'negate', field, _value) } /** * > ⚠ There is an [issue with file upload in Puppeteer 2.1.0 & 2.1.1](https://github.com/puppeteer/puppeteer/issues/5420), downgrade to 2.0.0 if you face it. * * {{> 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, locator, 'Field') await els[0].uploadFile(file) return this._waitForAction() } /** * {{> selectOption }} */ async selectOption(select, option) { const els = await findVisibleFields.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>') } highlightActiveElement.call(this, els[0], await this._getContext()) 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() && el))).filter(v => v) // Puppeteer visibility was ignored? | Remove when Puppeteer is fixed els = await Promise.all(els.map(async el => (await el.evaluate(node => window.getComputedStyle(node).visibility !== 'hidden' && window.getComputedStyle(node).display !== 'none')) && el)) 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) } /** * {{> seeTextEquals }} */ 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 (${new Locator(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 (${new Locator(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} not 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] } /** * {{> waitForCookie }} */ async waitForCookie(name, sec) { // by default, we will retry 3 times let retries = 3 const waitTimeout = sec ? sec * 1000 : this.options.waitForTimeout if (sec) { retries = sec } else { retries = Math.ceil(waitTimeout / 1000) - 1 } return promiseRetry( async (retry, number) => { const _grabCookie = async name => { const cookies = await this.page.cookies() const cookie = cookies.filter(c => c.name === name) if (cookie.length === 0) throw Error(`Cookie ${name} is not found after ${retries}s`) } this.debugSection('Wait for cookie: ', name) if (number > 1) this.debugSection('Retrying... Attempt #', number) try { await _grabCookie(name) } catch (e) { retry(e) } }, { retries, maxTimeout: 1000 }, ) } /** * {{> 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]) } /** * If a function returns a Promise, tt will wait for its resolution. * * {{> executeScript }} */ async executeScript(...args) { let context = await this._getContext() if (this.context && this.context.constructor.name === 'CdpFrame') { context = this.context // switching to iframe context } return context.evaluate.apply(context, args) } /** * Asynchronous scripts can also be executed with `executeScript` if a function returns a Promise. * {{> executeAsyncScript }} */ async executeAsyncScript(...args) { const asyncFn = function () { const args = Array.from(arguments) const fn = eval(`(${args.shift()})`) 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) } /** * {{> grabTextFromAll }} * {{ react }} */ async grabTextFromAll(locator) { const els = await this._locate(locator) const texts = [] for (const el of els) { texts.push(await (await el.getProperty('innerText')).jsonValue()) } return texts } /** * {{> grabTextFrom }} * {{ react }} */ async grabTextFrom(locator) { const texts = await this.grabTextFromAll(locator) assertElementExists(texts, locator) if (texts.length > 1) { this.debugSection('GrabText', `Using first element out of ${texts.length}`) } return texts[0] } /** * {{> grabValueFromAll }} */ async grabValueFromAll(locator) { const els = await findFields.call(this, locator) const values = [] for (const el of els) { values.push(await (await el.getProperty('value')).jsonValue()) } return values } /** * {{> grabValueFrom }} */ async grabValueFrom(locator) { const values = await this.grabValueFromAll(locator) assertElementExists(values, locator) if (values.length > 1) { this.debugSection('GrabValue', `Using first element out of ${values.length}`) } return values[0] } /** * {{> grabHTMLFromAll }} */ async grabHTMLFromAll(locator) { const els = await this._locate(locator) const values = await Promise.all(els.map(el => el.evaluate(element => element.innerHTML, el))) return values } /** * {{> grabHTMLFrom }} */ async grabHTMLFrom(locator) { const html = await this.grabHTMLFromAll(locator) assertElementExists(html, locator) if (html.length > 1) { this.debugSection('GrabHTML', `Using first