UNPKG

codeceptjs

Version:

Supercharged End 2 End Testing Framework for NodeJS

1,694 lines (1,526 loc) 110 kB
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