codeceptjs
Version:
Supercharged End 2 End Testing Framework for NodeJS
1,780 lines (1,597 loc) • 91.2 kB
JavaScript
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