codeceptjs
Version:
Supercharged End 2 End Testing Framework for NodeJS
1,694 lines (1,526 loc) • 110 kB
JavaScript
import axios from 'axios'
import fs from 'fs'
import fsExtra from 'fs-extra'
import path from 'path'
import Helper from '@codeceptjs/helper'
import { v4 as uuidv4 } from 'uuid'
import promiseRetry from 'promise-retry'
import Locator from '../locator.js'
import recorder from '../recorder.js'
import store from '../store.js'
import { checkFocusBeforeType, checkFocusBeforePressKey } from './extras/focusCheck.js'
import { includes as stringIncludes } from '../assert/include.js'
import { urlEquals, equals } from '../assert/equal.js'
import { empty } from '../assert/empty.js'
import { truth } from '../assert/truth.js'
import isElementClickable from './scripts/isElementClickable.js'
import {
xpathLocator,
ucfirst,
fileExists,
chunkArray,
toCamelCase,
clearString,
convertCssPropertiesToCamelCase,
screenshotOutputFolder,
getNormalizedKeyAttributeValue,
isModifierKey,
requireWithFallback,
normalizeSpacesInString,
normalizePath,
resolveUrl,
getMimeType,
base64EncodeFile,
} from '../utils.js'
import { isColorProperty, convertColorToRGBA } from '../colorUtils.js'
import ElementNotFound from './errors/ElementNotFound.js'
import MultipleElementsFound from './errors/MultipleElementsFound.js'
import RemoteBrowserConnectionRefused from './errors/RemoteBrowserConnectionRefused.js'
import Popup from './extras/Popup.js'
import Console from './extras/Console.js'
import { highlightElement } from './scripts/highlightElement.js'
import { blurElement } from './scripts/blurElement.js'
import { dropFile } from './scripts/dropFile.js'
import { dontSeeElementError, seeElementError, dontSeeElementInDOMError, seeElementInDOMError } from './errors/ElementAssertion.js'
import { dontSeeTraffic, seeTraffic, grabRecordedNetworkTraffics, stopRecordingTraffic, flushNetworkTraffics } from './network/actions.js'
import WebElement from '../element/WebElement.js'
import { selectElement } from './extras/elementSelection.js'
import { fillRichEditor } from './extras/richTextEditor.js'
let puppeteer
/**
* Wraps error objects that don't have a proper message property
* This is needed for ESM compatibility with Puppeteer error handling
*/
function wrapError(e) {
if (e && typeof e === 'object' && !e.message) {
const err = new Error(String(e))
err.stack = e.stack
return err
}
return e
}
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|string[]} [waitForNavigation=load] - when to consider navigation succeeded. Possible options: `load`, `domcontentloaded`, `networkidle0`, `networkidle2`. See [Puppeteer API](https://github.com/puppeteer/puppeteer/blob/main/docs/api/puppeteer.waitforoptions.md). 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/puppeteer/puppeteer/blob/main/docs/api/puppeteer.launchoptions.md).
* @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/puppeteer/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 will be loaded dynamically in _init method
// 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,
strict: 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 {
// In ESM, puppeteer will be checked via dynamic import in _init
// The import will fail at module load time if puppeteer is missing
return null
} catch (e) {
return ['puppeteer']
}
}
async _init() {
// Load puppeteer dynamically with fallback
if (!puppeteer) {
try {
const puppeteerModule = await import('puppeteer')
puppeteer = puppeteerModule.default || puppeteerModule
this.debugSection('Puppeteer', `Loaded puppeteer successfully, launch available: ${!!puppeteer.launch}`)
} catch (e) {
try {
const puppeteerModule = await import('puppeteer-core')
puppeteer = puppeteerModule.default || puppeteerModule
this.debugSection('Puppeteer', `Loaded puppeteer-core successfully, launch available: ${!!puppeteer.launch}`)
} catch (e2) {
throw new Error('Neither puppeteer nor puppeteer-core could be loaded. Please install one of them.')
}
}
} else {
this.debugSection('Puppeteer', `Puppeteer already loaded, launch available: ${!!puppeteer.launch}`)
}
}
_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
// Clear popup state to prevent leakage between tests
popupStore.clear()
// 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()
if (!defaultCtx) {
this.debug('Cannot restore session vars: default browser context is undefined')
return
}
try {
const existingPages = defaultCtx.targets().filter(t => t.type() === 'page')
if (existingPages && existingPages.length > 0) {
await this._setPage(await existingPages[0].page())
// Reset context-related variables to ensure clean state after session
this.context = await this.page
this.contextLocator = null
} else {
this.debug('Cannot restore session vars: no pages available')
}
} catch (err) {
this.debug(`Failed to restore session vars: ${err.message}`)
return
}
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() {
this.debugSection('Puppeteer', `Starting browser. Puppeteer available: ${!!puppeteer}, launch available: ${!!puppeteer?.launch}`)
if (!puppeteer) {
throw new Error('Puppeteer is not loaded. Make sure _init() was called before _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(fn, handle, ...args) {
// If handle is provided, evaluate directly on it to avoid "JavaScript world" errors
if (handle) {
return handle.evaluate(fn, ...args)
}
// Otherwise use the context
const context = await this._getContext()
return context.evaluateHandle(fn, ...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 el = await this._locateElement(locator)
if (!el) {
throw new ElementNotFound(locator, 'Element for within context')
}
this.context = el
this.withinLocator = new Locator(locator)
}
async _withinEnd() {
this.withinLocator = null
if (this.page && !this.page.isClosed?.()) {
this.context = await this.page.mainFrame().$('body')
} else {
this.context = null
}
}
_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 = `${`${store.outputDir}${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
}
try {
await this.page.goto(url, { waitUntil: this.options.waitForNavigation })
} catch (err) {
// Handle terminal navigation errors that shouldn't be retried
if (
err.message &&
(err.message.includes('ERR_ABORTED') || err.message.includes('frame was detached') || err.message.includes('Target page, context or browser has been closed') || err.message.includes('Navigation timeout'))
) {
// Mark this as a terminal error to prevent retries
const terminalError = new Error(err.message)
terminalError.isTerminal = true
throw terminalError
}
throw err
}
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 }}
*/
async moveCursorTo(locator, offsetX = 0, offsetY = 0) {
let context = null
if (typeof offsetX !== 'number') {
context = offsetX
offsetX = 0
}
let el
if (context) {
const contextEls = await findElements.call(this, this.page, context)
assertElementExists(contextEls, context, 'Context element')
const els = await findElements.call(this, contextEls[0], locator)
if (!els || els.length === 0) {
throw new ElementNotFound(locator, 'Element to move cursor to')
}
el = els[0]
} else {
el = await this._locateElement(locator)
if (!el) {
throw new ElementNotFound(locator, 'Element to move cursor to')
}
}
// Use manual mouse.move instead of .hover() so the offset can be added to the coordinates
const { x, y } = await getClickablePoint(el)
await this.page.mouse.move(x + offsetX, y + offsetY)
return this._waitForAction()
}
/**
* {{> focus }}
*
*/
async focus(locator) {
const el = await this._locateElement(locator)
if (!el) {
throw new ElementNotFound(locator, 'Element to focus')
}
await el.click()
await el.focus()
return this._waitForAction()
}
/**
* {{> blur }}
*
*/
async blur(locator) {
const el = await this._locateElement(locator)
if (!el) {
throw new ElementNotFound(locator, 'Element to blur')
}
await blurElement(el, 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 el = await this._locateElement(locator)
if (!el) {
throw new ElementNotFound(locator, 'Element to scroll into view')
}
await el.evaluate(el => el.scrollIntoView())
const elementCoordinates = await getClickablePoint(el)
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'});
* ```
*
*/
async _locate(locator) {
const context = await this.context
return findElements.call(this, context, locator)
}
/**
* Get single element by different locator types, including strict locator
* Should be used in custom helpers:
*
* ```js
* const element = await this.helpers['Puppeteer']._locateElement({name: 'password'});
* ```
*
*/
async _locateElement(locator) {
const context = await this.context
const elementIndex = store.currentStep?.opts?.elementIndex
if (this.options.strict || elementIndex) {
const elements = await findElements.call(this, context, locator)
if (elements.length === 0) {
throw new ElementNotFound(locator, 'Element', 'was not found')
}
return selectElement(elements, locator, this)
}
return findElement.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)
if (!els || els.length === 0) {
throw new ElementNotFound(locator, 'Checkbox or radio')
}
return selectElement(els, locator, this)
}
/**
* 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) {
const elements = await this._locate(locator)
return elements.map(element => new WebElement(element, this))
}
/**
* {{> grabWebElement }}
*
*/
async grabWebElement(locator) {
const elements = await this._locate(locator)
if (elements.length === 0) {
throw new ElementNotFound(locator, 'Element')
}
return new WebElement(elements[0], this)
}
async grabWebElement(locator) {
const els = await this._locate(locator)
assertElementExists(els, locator)
return els[0]
}
/**
* 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 }}
*/
async seeElement(locator, context = null) {
let els
if (context) {
const contextPage = await this.context
const contextEls = await findElements.call(this, contextPage, context)
assertElementExists(contextEls, context, 'Context element')
els = await findElements.call(this, contextEls[0], locator)
} else {
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 }}
*/
async dontSeeElement(locator, context = null) {
let els
if (context) {
const contextPage = await this.context
const contextEls = await findElements.call(this, contextPage, context)
assertElementExists(contextEls, context, 'Context element')
els = await findElements.call(this, contextEls[0], locator)
} else {
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 }}
*
*/
async click(locator = '//body', context = null) {
return proceedClick.call(this, locator, context)
}
/**
* {{> forceClick }}
*
*/
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 }}
*
*/
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(store.outputDir, 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(`${store.outputDir}/${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 }}
*
*/
async doubleClick(locator, context = null) {
return proceedClick.call(this, locator, context, { clickCount: 2 })
}
/**
* {{> rightClick }}
*
*/
async rightClick(locator, context = null) {
return proceedClick.call(this, locator, context, { button: 'right' })
}
/**
* Performs click at specific coordinates.
* If locator is provided, the coordinates are relative to the element.
* If locator is not provided, the coordinates are global page coordinates.
*
* ```js
* // Click at global coordinates (100, 200)
* I.clickXY(100, 200);
*
* // Click at coordinates (50, 30) relative to element
* I.clickXY('#someElement', 50, 30);
* ```
*
* @param {CodeceptJS.LocatorOrString|number} locator Element to click on or X coordinate if no element.
* @param {number} [x] X coordinate relative to element, or Y coordinate if locator is a number.
* @param {number} [y] Y coordinate relative to element.
* @returns {Promise<void>}
*/
async clickXY(locator, x, y) {
// If locator is a number, treat it as global X coordinate
if (typeof locator === 'number') {
const globalX = locator
const globalY = x
await this.page.mouse.click(globalX, globalY)
return this._waitForAction()
}
// Locator is provided, click relative to element
const els = await this._locate(locator)
assertElementExists(els, locator, 'Element to click')
const box = await els[0].boundingBox()
if (!box) {
throw new Error(`Element ${locator} is not visible or has no bounding box`)
}
const absoluteX = box.x + x
const absoluteY = box.y + y
await this.page.mouse.click(absoluteX, absoluteY)
return this._waitForAction()
}
/**
* {{> checkOption }}
*/
async checkOption(field, context = null) {
const elm = await this._locateCheckable(field, context)
let curentlyChecked = await elm
.getProperty('checked')
.then(checkedProperty => checkedProperty.jsonValue())
.catch(() => null)
if (!curentlyChecked) {
const ariaChecked = await elm.evaluate(el => el.getAttribute('aria-checked'))
curentlyChecked = ariaChecked === 'true'
}
if (!curentlyChecked) {
await elm.click()
return this._waitForAction()
}
}
/**
* {{> uncheckOption }}
*/
async uncheckOption(field, context = null) {
const elm = await this._locateCheckable(field, context)
let curentlyChecked = await elm
.getProperty('checked')
.then(checkedProperty => checkedProperty.jsonValue())
.catch(() => null)
if (!curentlyChecked) {
const ariaChecked = await elm.evaluate(el => el.getAttribute('aria-checked'))
curentlyChecked = ariaChecked === 'true'
}
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 ([puppeteer/puppeteer#1313](https://github.com/puppeteer/puppeteer/issues/1313)).
*
* {{> pressKeyWithKeyNormalization }}
*/
async pressKey(key) {
await checkFocusBeforePressKey(this, 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) {
await checkFocusBeforeType(this)
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 }}
*/
async fillField(field, value, context = null) {
let els = await findVisibleFields.call(this, field, context)
if (!els.length) {
els = await findFields.call(this, field, context)
}
assertElementExists(els, field, 'Field')
const el = selectElement(els, field, this)
if (await fillRichEditor(this, el, value)) {
highlightActiveElement.call(this, el, await this._getContext())
return this._waitForAction()
}
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, context = null) {
return this.fillField(field, '', context)
}
/**
* {{> appendField }}
*
*/
async appendField(field, value, context = null) {
const els = await findVisibleFields.call(this, field, context)
assertElementExists(els, field, 'Field')
const el = selectElement(els, field, this)
highlightActiveElement.call(this, el, await this._getContext())
await el.press('End')
await el.type(value.toString(), { delay: this.options.pressKeyDelay })
return this._waitForAction()
}
/**
* {{> seeInField }}
*/
async seeInField(field, value, context = null) {
const _value = typeof value === 'boolean' ? value : value.toString()
return proceedSeeInField.call(this, 'assert', field, _value, context)
}
/**
* {{> dontSeeInField }}
*/
async dontSeeInField(field, value, context = null) {
const _value = typeof value === 'boolean' ? value : value.toString()
return proceedSeeInField.call(this, 'negate', field, _value, context)
}
/**
* > ⚠ 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, context = null) {
const file = path.join(store.codeceptDir, pathToFile)
if (!fileExists(file)) {
throw new Error(`File at ${file} can not be found on local system`)
}
const els = await findFields.call(this, locator, context)
if (els.length) {
const el = selectElement(els, locator, this)
const tag = await el.evaluate(el => el.tagName)
const type = await el.evaluate(el => el.type)
if (tag === 'INPUT' && type === 'file') {
await el.uploadFile(file)
return this._waitForAction()
}
}
const targetEls = els.length ? els : await this._locate(locator)
assertElementExists(targetEls, locator, 'Element')
const el = selectElement(targetEls, locator, this)
const fileData = {
base64Content: base64EncodeFile(file),
fileName: path.basename(file),
mimeType: getMimeType(path.basename(file)),
}
await el.evaluate(dropFile, fileData)
return this._waitForAction()
}
/**
* {{> selectOption }}
*/
async selectOption(select, option, context = null) {
const pageContext = await this._getContext()
const matchedLocator = new Locator(select)
let contextEl
if (context) {
c