codeceptjs
Version:
Supercharged End 2 End Testing Framework for NodeJS
1,435 lines (1,300 loc) • 136 kB
JavaScript
const path = require('path')
const fs = require('fs')
const Helper = require('@codeceptjs/helper')
const { v4: uuidv4 } = require('uuid')
const assert = require('assert')
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 {
xpathLocator,
ucfirst,
fileExists,
chunkArray,
convertCssPropertiesToCamelCase,
screenshotOutputFolder,
getNormalizedKeyAttributeValue,
isModifierKey,
clearString,
requireWithFallback,
normalizeSpacesInString,
relativeDir,
} = 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 { findReact, findVue, findByPlaywrightLocator } = require('./extras/PlaywrightReactVueLocator')
const WebElement = require('../element/WebElement')
let playwright
let perfTiming
let defaultSelectorEnginesInitialized = false
let registeredCustomLocatorStrategies = new Set()
let globalCustomLocatorStrategies = new Map()
const popupStore = new Popup()
const consoleLogStore = new Console()
const availableBrowsers = ['chromium', 'webkit', 'firefox', 'electron']
const { setRestartStrategy, restartsSession, restartsContext, restartsBrowser } = require('./extras/PlaywrightRestartOpts')
const { createValueEngine, createDisabledEngine } = require('./extras/PlaywrightPropEngine')
const { seeElementError, dontSeeElementError, dontSeeElementInDOMError, seeElementInDOMError } = require('./errors/ElementAssertion')
const { dontSeeTraffic, seeTraffic, grabRecordedNetworkTraffics, stopRecordingTraffic, flushNetworkTraffics } = require('./network/actions')
const pathSeparator = path.sep
/**
* ## Configuration
*
* This helper should be configured in codecept.conf.(js|ts)
*
* @typedef PlaywrightConfig
* @type {object}
* @prop {string} [url] - base url of website to be tested
* @prop {'chromium' | 'firefox'| 'webkit' | 'electron'} [browser='chromium'] - a browser to test on, either: `chromium`, `firefox`, `webkit`, `electron`. Default: chromium.
* @prop {boolean} [show=true] - show browser window.
* @prop {string|boolean} [restart=false] - restart strategy between tests. Possible values:
* * 'context' or **false** - restarts [browser context](https://playwright.dev/docs/api/class-browsercontext) but keeps running browser. Recommended by Playwright team to keep tests isolated.
* * 'browser' or **true** - closes browser and opens it again between tests.
* * 'session' or 'keep' - keeps browser context and session, but cleans up cookies and localStorage between tests. The fastest option when running tests in windowed mode. Works with `keepCookies` and `keepBrowserState` options. This behavior was default before CodeceptJS 3.1
* @prop {number} [timeout=1000] - - [timeout](https://playwright.dev/docs/api/class-page#page-set-default-timeout) in ms of all Playwright actions .
* @prop {boolean} [disableScreenshots=false] - don't save screenshot on failure.
* @prop {any} [emulate] - browser in device emulation mode.
* @prop {boolean} [video=false] - enables video recording for failed tests; videos are saved into `output/videos` folder
* @prop {boolean} [keepVideoForPassedTests=false] - save videos for passed tests; videos are saved into `output/videos` folder
* @prop {boolean} [trace=false] - record [tracing information](https://playwright.dev/docs/trace-viewer) with screenshots and snapshots.
* @prop {boolean} [keepTraceForPassedTests=false] - save trace for passed tests.
* @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} [keepBrowserState=false] - keep browser state between tests when `restart` is set to 'session'.
* @prop {boolean} [keepCookies=false] - keep cookies between tests when `restart` is set to 'session'.
* @prop {number} [waitForAction] - how long to wait after click, doubleClick or PressKey actions in ms. Default: 100.
* @prop {'load' | 'domcontentloaded' | 'commit'} [waitForNavigation] - When to consider navigation succeeded. Possible options: `load`, `domcontentloaded`, `commit`. Choose one of those options is possible. See [Playwright API](https://playwright.dev/docs/api/class-page#page-wait-for-url).
* @prop {number} [pressKeyDelay=10] - Delay between key presses in ms. Used when calling Playwrights page.type(...) in fillField/appendField
* @prop {number} [getPageTimeout] - config option to set maximum navigation time in milliseconds.
* @prop {number} [waitForTimeout] - default wait* timeout in ms. Default: 1000.
* @prop {object} [basicAuth] - the basic authentication to pass to base url. Example: {username: 'username', password: 'password'}
* @prop {string} [windowSize] - default window size. Set a dimension like `640x480`.
* @prop {'dark' | 'light' | 'no-preference'} [colorScheme] - default color scheme. Possible values: `dark` | `light` | `no-preference`.
* @prop {string} [userAgent] - user-agent string.
* @prop {string} [locale] - locale string. Example: 'en-GB', 'de-DE', 'fr-FR', ...
* @prop {boolean} [manualStart] - do not start browser before a test, start it manually inside a helper with `this.helpers["Playwright"]._startBrowser()`.
* @prop {object} [chromium] - pass additional chromium options
* @prop {object} [firefox] - pass additional firefox options
* @prop {object} [electron] - (pass additional electron options
* @prop {any} [channel] - (While Playwright can operate against the stock Google Chrome and Microsoft Edge browsers available on the machine. In particular, current Playwright version will support Stable and Beta channels of these browsers. See [Google Chrome & Microsoft Edge](https://playwright.dev/docs/browsers/#google-chrome--microsoft-edge).
* @prop {string[]} [ignoreLog] - An array with console message types that are not logged to debug log. Default value is `['warning', 'log']`. E.g. you can set `[]` to log all messages. See all possible [values](https://playwright.dev/docs/api/class-consolemessage#console-message-type).
* @prop {boolean} [ignoreHTTPSErrors] - Allows access to untrustworthy pages, e.g. to a page with an expired certificate. Default value is `false`
* @prop {boolean} [bypassCSP] - bypass Content Security Policy or CSP
* @prop {boolean} [highlightElement] - highlight the interacting elements. Default: false. Note: only activate under verbose mode (--verbose).
* @prop {object} [recordHar] - record HAR and will be saved to `output/har`. See more of [HAR options](https://playwright.dev/docs/api/class-browser#browser-new-context-option-record-har).
* @prop {string} [testIdAttribute=data-testid] - locate elements based on the testIdAttribute. See more of [locate by test id](https://playwright.dev/docs/locators#locate-by-test-id).
* @prop {object} [customLocatorStrategies] - custom locator strategies. An object with keys as strategy names and values as JavaScript functions. Example: `{ byRole: (selector, root) => { return root.querySelector(`[role="${selector}"]`) } }`
* @prop {string|object} [storageState] - Playwright storage state (path to JSON file or object)
* passed directly to `browser.newContext`.
* If a Scenario is declared with a `cookies` option (e.g. `Scenario('name', { cookies: [...] }, fn)`),
* those cookies are used instead and the configured `storageState` is ignored (no merge).
* May include session cookies, auth tokens, localStorage and (if captured with
* `grabStorageState({ indexedDB: true })`) IndexedDB data; treat as sensitive and do not commit.
*/
const config = {}
/**
* Uses [Playwright](https://github.com/microsoft/playwright) library to run tests inside:
*
* * Chromium
* * Firefox
* * Webkit (Safari)
*
* This helper works with a browser out of the box with no additional tools required to install.
*
* Requires `playwright` or `playwright-core` package version ^1 to be installed:
*
* ```
* npm i playwright@^1.18 --save
* ```
* or
* ```
* npm i playwright-core@^1.18 --save
* ```
*
* Breaking Changes: if you use Playwright v1.38 and later, it will no longer download browsers automatically.
*
* Run `npx playwright install` to download browsers after `npm install`.
*
* Using playwright-core package, will prevent the download of browser binaries and allow connecting to an existing browser installation or for connecting to a remote one.
*
*
* <!-- configuration -->
*
* #### Video Recording Customization
*
* By default, video is saved to `output/video` dir. You can customize this path by passing `dir` option to `recordVideo` option.
*
* `video`: enables video recording for failed tests; videos are saved into `output/videos` folder
* * `keepVideoForPassedTests`: - save videos for passed tests
* * `recordVideo`: [additional options for videos customization](https://playwright.dev/docs/next/api/class-browser#browser-new-context)
*
* #### Trace Recording Customization
*
* Trace recording provides complete information on test execution and includes DOM snapshots, 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
*
* #### HAR Recording Customization
*
* A HAR file is an HTTP Archive file that contains a record of all the network requests that are made when a page is loaded.
* It contains information about the request and response headers, cookies, content, timings, and more. You can use HAR files to mock network requests in your tests.
* HAR will be saved to `output/har`. More info could be found here https://playwright.dev/docs/api/class-browser#browser-new-context-option-record-har.
*
* ```
* ...
* recordHar: {
* mode: 'minimal', // possible values: 'minimal'|'full'.
* content: 'embed' // possible values: "omit"|"embed"|"attach".
* }
* ...
*```
*
* #### Example #1: Wait for 0 network connections.
*
* ```js
* {
* helpers: {
* Playwright : {
* url: "http://localhost",
* restart: false,
* waitForNavigation: "networkidle0",
* waitForAction: 500
* }
* }
* }
* ```
*
* #### Example #2: Wait for DOMContentLoaded event
*
* ```js
* {
* helpers: {
* Playwright : {
* url: "http://localhost",
* restart: false,
* waitForNavigation: "domcontentloaded",
* waitForAction: 500
* }
* }
* }
* ```
*
* #### Example #3: Debug in window mode
*
* ```js
* {
* helpers: {
* Playwright : {
* url: "http://localhost",
* show: true
* }
* }
* }
* ```
*
* #### Example #4: Connect to remote browser by specifying [websocket endpoint](https://playwright.dev/docs/api/class-browsertype#browsertypeconnectparams)
*
* ```js
* {
* helpers: {
* Playwright: {
* url: "http://localhost",
* chromium: {
* browserWSEndpoint: 'ws://localhost:9222/devtools/browser/c5aa6160-b5bc-4d53-bb49-6ecb36cd2e0a',
* cdpConnection: false // default is false
* }
* }
* }
* }
* ```
*
* #### Example #5: Testing with Chromium extensions
*
* [official docs](https://github.com/microsoft/playwright/blob/v0.11.0/docs/api.md#working-with-chrome-extensions)
*
* ```js
* {
* helpers: {
* Playwright: {
* url: "http://localhost",
* show: true // headless mode not supported for extensions
* chromium: {
* // Note: due to this would launch persistent context, so to avoid the error when running tests with run-workers a timestamp would be appended to the defined folder name. For instance: playwright-tmp_1692715649511
* userDataDir: '/tmp/playwright-tmp', // necessary to launch the browser in normal mode instead of incognito,
* args: [
* `--disable-extensions-except=${pathToExtension}`,
* `--load-extension=${pathToExtension}`
* ]
* }
* }
* }
* }
* ```
*
* #### Example #6: Launch tests emulating iPhone 6
*
*
*
* ```js
* const { devices } = require('playwright');
*
* {
* helpers: {
* Playwright: {
* url: "http://localhost",
* emulate: devices['iPhone 6'],
* }
* }
* }
* ```
*
* #### Example #7: Launch test with a specific user locale
*
* ```js
* {
* helpers: {
* Playwright : {
* url: "http://localhost",
* locale: "fr-FR",
* }
* }
* }
* ```
*
* * #### Example #8: Launch test with a specific color scheme
*
* ```js
* {
* helpers: {
* Playwright : {
* url: "http://localhost",
* colorScheme: "dark",
* }
* }
* }
* ```
*
* * #### Example #9: Launch electron test
*
* ```js
* {
* helpers: {
* Playwright: {
* browser: 'electron',
* electron: {
* executablePath: require("electron"),
* args: [path.join('../', "main.js")],
* },
* }
* },
* }
* ```
*
* Note: When connecting to remote browser `show` and specific `chrome` options (e.g. `headless` or `devtools`) are ignored.
*
* ## Access From Helpers
*
* Receive Playwright client from a custom helper by accessing `browser` for the Browser object or `page` for the current Page object:
*
* ```js
* const { browser } = this.helpers.Playwright;
* await browser.pages(); // List of pages in the browser
*
* // get current page
* const { page } = this.helpers.Playwright;
* await page.url(); // Get the url of the current page
*
* const { browserContext } = this.helpers.Playwright;
* await browserContext.cookies(); // get current browser context
* ```
*/
class Playwright extends Helper {
constructor(config) {
super(config)
playwright = requireWithFallback('playwright', 'playwright-core')
// set defaults
this.isRemoteBrowser = false
this.isRunning = false
this.isAuthenticated = false
this.sessionPages = {}
this.activeSessionName = ''
this.isElectron = false
this.isCDPConnection = false
this.electronSessions = []
this.storageState = null
// 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
this.customLocatorStrategies = typeof config.customLocatorStrategies === 'object' && config.customLocatorStrategies !== null ? config.customLocatorStrategies : null
this._customLocatorsRegistered = false
// Add custom locator strategies to global registry for early registration
if (this.customLocatorStrategies) {
for (const [strategyName, strategyFunction] of Object.entries(this.customLocatorStrategies)) {
globalCustomLocatorStrategies.set(strategyName, strategyFunction)
}
}
// override defaults with config
this._setConfig(config)
// pass storageState directly (string path or object) and let Playwright handle errors/missing file
if (typeof config.storageState !== 'undefined') {
this.storageState = config.storageState
}
}
_validateConfig(config) {
const defaults = {
// options to emulate context
emulate: {},
browser: 'chromium',
waitForAction: 100,
waitForTimeout: 1000,
pressKeyDelay: 10,
timeout: 5000,
fullPageScreenshots: false,
disableScreenshots: false,
ignoreLog: ['warning', 'log'],
uniqueScreenshotNames: false,
manualStart: false,
getPageTimeout: 30000,
waitForNavigation: 'load',
restart: false,
keepCookies: false,
keepBrowserState: false,
show: false,
defaultPopupAction: 'accept',
use: { actionTimeout: 0 },
ignoreHTTPSErrors: false, // Adding it here o that context can be set up to ignore the SSL errors,
highlightElement: false,
storageState: undefined,
}
process.env.testIdAttribute = 'data-testid'
config = Object.assign(defaults, config)
if (availableBrowsers.indexOf(config.browser) < 0) {
throw new Error(`Invalid config. Can't use browser "${config.browser}". Accepted values: ${availableBrowsers.join(', ')}`)
}
return config
}
_getOptionsForBrowser(config) {
if (config[config.browser]) {
if (config[config.browser].browserWSEndpoint && config[config.browser].browserWSEndpoint.wsEndpoint) {
config[config.browser].browserWSEndpoint = config[config.browser].browserWSEndpoint.wsEndpoint
}
return {
...config[config.browser],
wsEndpoint: config[config.browser].browserWSEndpoint,
}
}
return {}
}
_setConfig(config) {
this.options = this._validateConfig(config)
setRestartStrategy(this.options)
this.playwrightOptions = {
headless: !this.options.show,
...this._getOptionsForBrowser(config),
}
if (this.options.channel && this.options.browser === 'chromium') {
this.playwrightOptions.channel = this.options.channel
}
if (this.options.video) {
// set the video resolution with window size
let size = parseWindowSize(this.options.windowSize)
// if the video resolution is passed, set the record resoultion with that resolution
if (this.options.recordVideo && this.options.recordVideo.size) {
size = parseWindowSize(this.options.recordVideo.size)
}
this.options.recordVideo = { size }
}
if (this.options.recordVideo && !this.options.recordVideo.dir) {
this.options.recordVideo.dir = `${global.output_dir}/videos/`
}
this.isRemoteBrowser = !!this.playwrightOptions.browserWSEndpoint
this.isElectron = this.options.browser === 'electron'
this.userDataDir = this.playwrightOptions.userDataDir ? `${this.playwrightOptions.userDataDir}_${Date.now().toString()}` : undefined
this.isCDPConnection = this.playwrightOptions.cdpConnection
popupStore.defaultAction = this.options.defaultPopupAction
}
static _config() {
return [
{
name: 'browser',
message: 'Browser in which testing will be performed. Possible options: chromium, firefox, webkit or electron',
default: 'chromium',
},
{
name: 'url',
message: 'Base url of site to be tested',
default: 'http://localhost',
when: answers => answers.Playwright_browser !== 'electron',
},
{
name: 'show',
message: 'Show browser window',
default: true,
type: 'confirm',
when: answers => answers.Playwright_browser !== 'electron',
},
]
}
static _checkRequirements() {
try {
requireWithFallback('playwright', 'playwright-core')
} catch (e) {
return ['playwright@^1.18']
}
}
async _init() {
// register an internal selector engine for reading value property of elements in a selector
try {
if (!defaultSelectorEnginesInitialized) {
await playwright.selectors.register('__value', createValueEngine)
await playwright.selectors.register('__disabled', createDisabledEngine)
if (process.env.testIdAttribute) await playwright.selectors.setTestIdAttribute(process.env.testIdAttribute)
defaultSelectorEnginesInitialized = true
}
// Register all custom locator strategies from the global registry
for (const [strategyName, strategyFunction] of globalCustomLocatorStrategies.entries()) {
if (!registeredCustomLocatorStrategies.has(strategyName)) {
try {
// Create a selector engine factory function exactly like createValueEngine pattern
// Capture variables in closure to avoid reference issues
const createCustomEngine = ((name, func) => {
return () => {
return {
create() {
return null
},
query(root, selector) {
try {
if (!root) return null
const result = func(selector, root)
return Array.isArray(result) ? result[0] : result
} catch (error) {
console.warn(`Error in custom locator "${name}":`, error)
return null
}
},
queryAll(root, selector) {
try {
if (!root) return []
const result = func(selector, root)
return Array.isArray(result) ? result : result ? [result] : []
} catch (error) {
console.warn(`Error in custom locator "${name}":`, error)
return []
}
},
}
}
})(strategyName, strategyFunction)
await playwright.selectors.register(strategyName, createCustomEngine)
registeredCustomLocatorStrategies.add(strategyName)
} catch (error) {
if (!error.message.includes('already registered')) {
console.warn(`Failed to register custom locator strategy '${strategyName}':`, error)
} else {
console.log(`Custom locator strategy '${strategyName}' already registered`)
}
}
}
}
} catch (e) {
console.warn(e)
}
}
_beforeSuite() {
if ((restartsSession() || restartsContext()) && !this.options.manualStart && !this.isRunning) {
this.debugSection('Session', 'Starting singleton browser session')
return this._startBrowser()
}
}
async _before(test) {
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 (restartsBrowser() && !this.options.manualStart) await this._startBrowser()
if (!this.isRunning && !this.options.manualStart) await this._startBrowser()
this.isAuthenticated = false
if (this.isElectron) {
this.browserContext = this.browser.context()
} else if (this.playwrightOptions.userDataDir) {
this.browserContext = this.browser
} else {
const contextOptions = {
ignoreHTTPSErrors: this.options.ignoreHTTPSErrors,
acceptDownloads: true,
...this.options.emulate,
}
if (this.options.basicAuth) {
contextOptions.httpCredentials = this.options.basicAuth
this.isAuthenticated = true
}
if (this.options.bypassCSP) contextOptions.bypassCSP = this.options.bypassCSP
if (this.options.recordVideo) contextOptions.recordVideo = this.options.recordVideo
if (this.options.recordHar) {
const harExt = this.options.recordHar.content && this.options.recordHar.content === 'attach' ? 'zip' : 'har'
const fileName = `${`${global.output_dir}${path.sep}har${path.sep}${uuidv4()}_${clearString(this.currentRunningTest.title)}`.slice(0, 245)}.${harExt}`
const dir = path.dirname(fileName)
if (!fileExists(dir)) fs.mkdirSync(dir)
this.options.recordHar.path = fileName
this.currentRunningTest.artifacts.har = fileName
contextOptions.recordHar = this.options.recordHar
}
// load pre-saved cookies
if (test?.opts?.cookies) contextOptions.storageState = { cookies: test.opts.cookies }
else if (this.storageState) contextOptions.storageState = this.storageState
if (this.options.userAgent) contextOptions.userAgent = this.options.userAgent
if (this.options.locale) contextOptions.locale = this.options.locale
if (this.options.colorScheme) contextOptions.colorScheme = this.options.colorScheme
this.contextOptions = contextOptions
if (!this.browserContext || !restartsSession()) {
this.debugSection('New Session', JSON.stringify(this.contextOptions))
this.browserContext = await this.browser.newContext(this.contextOptions) // Adding the HTTPSError ignore in the context so that we can ignore those errors
}
}
let mainPage
if (this.isElectron) {
mainPage = await this.browser.firstWindow()
} else {
try {
const existingPages = await this.browserContext.pages()
mainPage = existingPages[0] || (await this.browserContext.newPage())
} catch (e) {
if (this.playwrightOptions.userDataDir) {
this.browser = await playwright[this.options.browser].launchPersistentContext(this.userDataDir, this.playwrightOptions)
this.browserContext = this.browser
const existingPages = await this.browserContext.pages()
mainPage = existingPages[0]
}
}
}
await targetCreatedHandler.call(this, mainPage)
await this._setPage(mainPage)
try {
// set metadata for reporting
test.meta.browser = this.browser.browserType().name()
test.meta.browserVersion = this.browser.version()
test.meta.windowSize = `${this.page.viewportSize().width}x${this.page.viewportSize().height}`
} catch (e) {
this.debug('Failed to set metadata for reporting')
}
if (this.options.trace) await this.browserContext.tracing.start({ screenshots: true, snapshots: true })
return this.browser
}
async _after() {
if (!this.isRunning) return
// Clear popup state to prevent leakage between tests
popupStore.clear()
if (this.isElectron) {
this.browser.close()
this.electronSessions.forEach(session => session.close())
return
}
if (restartsSession()) {
return refreshContextSession.bind(this)()
}
if (restartsBrowser()) {
this.isRunning = false
return this._stopBrowser()
}
// close other sessions
try {
if ((await this.browser)._type === 'Browser') {
const contexts = await this.browser.contexts()
const currentContext = contexts[0]
if (currentContext && (this.options.keepCookies || this.options.keepBrowserState)) {
this.storageState = await currentContext.storageState()
}
await Promise.all(contexts.map(c => c.close()))
}
} catch (e) {
console.log(e)
}
// await this.closeOtherTabs();
return this.browser
}
_afterSuite() {}
async _finishTest() {
if ((restartsSession() || restartsContext()) && this.isRunning) return this._stopBrowser()
}
_session() {
const defaultContext = this.browserContext
return {
start: async (sessionName = '', config) => {
this.debugSection('New Context', config ? JSON.stringify(config) : 'opened')
this.activeSessionName = sessionName
let browserContext
let page
if (this.isElectron) {
const browser = await playwright._electron.launch(this.playwrightOptions)
this.electronSessions.push(browser)
browserContext = browser.context()
page = await browser.firstWindow()
} else {
try {
browserContext = await this.browser.newContext(Object.assign(this.contextOptions, config))
page = await browserContext.newPage()
} catch (e) {
if (this.playwrightOptions.userDataDir) {
browserContext = await playwright[this.options.browser].launchPersistentContext(`${this.userDataDir}_${this.activeSessionName}`, this.playwrightOptions)
this.browser = browserContext
page = await browserContext.pages()[0]
}
}
}
if (this.options.trace) await browserContext.tracing.start({ screenshots: true, snapshots: true })
await targetCreatedHandler.call(this, page)
await this._setPage(page)
// Create a new page inside context.
return browserContext
},
stop: async () => {
// is closed by _after
},
loadVars: async context => {
if (context) {
this.browserContext = context
const existingPages = await context.pages()
this.sessionPages[this.activeSessionName] = existingPages[0]
return this._setPage(this.sessionPages[this.activeSessionName])
}
},
restoreVars: async session => {
this.withinLocator = null
this.browserContext = defaultContext
if (!session) {
this.activeSessionName = ''
} else {
this.activeSessionName = session
}
const existingPages = await this.browserContext.pages()
await this._setPage(existingPages[0])
return this._waitForAction()
},
}
}
/**
* Use Playwright 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/microsoft/playwright/blob/main/docs/src/api/class-page.md), [`browserContext`](https://github.com/microsoft/playwright/blob/main/docs/src/api/class-browsercontext.md) [`browser`](https://github.com/microsoft/playwright/blob/main/docs/src/api/class-browser.md) } objects from Playwright API are available.
*
* ```js
* I.usePlaywrightTo('emulate offline mode', async ({ browserContext }) => {
* await browserContext.setOffline(true);
* });
* ```
*
* @param {string} description used to show in logs.
* @param {function} fn async function that executed with Playwright helper as arguments
*/
usePlaywrightTo(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.page = page
if (!page) return
this.browserContext.setDefaultTimeout(0)
page.setDefaultNavigationTimeout(this.options.getPageTimeout)
page.setDefaultTimeout(this.options.timeout)
page.on('crash', async () => {
console.log('ERROR: Page has crashed, closing page!')
await page.close()
})
this.context = await this.page
this.contextLocator = null
await page.bringToFront()
}
/**
* Add the 'dialog' event listener to a page
* @page {playwright.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.removeAllListeners('dialog')
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() {
// Ensure custom locator strategies are registered before browser launch
await this._init()
if (this.isElectron) {
this.browser = await playwright._electron.launch(this.playwrightOptions)
} else if (this.isRemoteBrowser && this.isCDPConnection) {
try {
this.browser = await playwright[this.options.browser].connectOverCDP(this.playwrightOptions)
} catch (err) {
if (err.toString().indexOf('ECONNREFUSED')) {
throw new RemoteBrowserConnectionRefused(err)
}
throw err
}
} else if (this.isRemoteBrowser) {
try {
this.browser = await playwright[this.options.browser].connect(this.playwrightOptions)
} catch (err) {
if (err.toString().indexOf('ECONNREFUSED')) {
throw new RemoteBrowserConnectionRefused(err)
}
throw err
}
} else if (this.playwrightOptions.userDataDir) {
this.browser = await playwright[this.options.browser].launchPersistentContext(this.userDataDir, this.playwrightOptions)
} else {
this.browser = await playwright[this.options.browser].launch(this.playwrightOptions)
}
// works only for Chromium
this.browser.on('targetchanged', target => {
this.debugSection('Url', target.url())
})
this.isRunning = true
return this.browser
}
_lookupCustomLocator(customStrategy) {
if (typeof this.customLocatorStrategies !== 'object' || this.customLocatorStrategies === null) {
return null
}
const strategy = this.customLocatorStrategies[customStrategy]
return typeof strategy === 'function' ? strategy : null
}
_isCustomLocator(locator) {
const locatorObj = new Locator(locator)
if (locatorObj.isCustom()) {
const customLocator = this._lookupCustomLocator(locatorObj.type)
if (customLocator) {
return true
}
throw new Error('Please define "customLocatorStrategies" as an Object and the Locator Strategy as a "function".')
}
return false
}
_isCustomLocatorStrategyDefined() {
return !!(this.customLocatorStrategies && Object.keys(this.customLocatorStrategies).length > 0)
}
/**
* Create a new browser context with a page. \
* Usually it should be run from a custom helper after call of `_startBrowser()`
* @param {object} [contextOptions] See https://playwright.dev/docs/api/class-browser#browser-new-context
*/
async _createContextPage(contextOptions) {
this.browserContext = await this.browser.newContext(contextOptions)
// Register custom locator strategies for this context
await this._registerCustomLocatorStrategies()
const page = await this.browserContext.newPage()
targetCreatedHandler.call(this, page)
await this._setPage(page)
}
async _registerCustomLocatorStrategies() {
if (!this.customLocatorStrategies) return
for (const [strategyName, strategyFunction] of Object.entries(this.customLocatorStrategies)) {
if (!registeredCustomLocatorStrategies.has(strategyName)) {
try {
const createCustomEngine = ((name, func) => {
return () => {
return {
create(root, target) {
return null
},
query(root, selector) {
try {
if (!root) return null
const result = func(selector, root)
return Array.isArray(result) ? result[0] : result
} catch (error) {
console.warn(`Error in custom locator "${name}":`, error)
return null
}
},
queryAll(root, selector) {
try {
if (!root) return []
const result = func(selector, root)
return Array.isArray(result) ? result : result ? [result] : []
} catch (error) {
console.warn(`Error in custom locator "${name}":`, error)
return []
}
},
}
}
})(strategyName, strategyFunction)
await playwright.selectors.register(strategyName, createCustomEngine)
registeredCustomLocatorStrategies.add(strategyName)
} catch (error) {
if (!error.message.includes('already registered')) {
console.warn(`Failed to register custom locator strategy '${strategyName}':`, error)
} else {
console.log(`Custom locator strategy '${strategyName}' already registered`)
}
}
}
}
}
_getType() {
return this.browser._type
}
async _stopBrowser() {
this.withinLocator = null
await this._setPage(null)
this.context = null
this.frame = null
popupStore.clear()
if (this.options.recordHar) await this.browserContext.close()
this.browserContext = null
await this.browser.close()
this.browser = null
this.isRunning = false
}
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)) {
await this.switchTo(null)
return 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)
assertElementExists(el, locator)
this.context = el
this.contextLocator = locator
this.withinLocator = new Locator(locator)
}
async _withinEnd() {
this.withinLocator = null
this.context = await this.page
this.contextLocator = null
this.frame = 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 (this.isElectron) {
throw new Error('Cannot open pages inside an Electron container')
}
if (!/^\w+\:(\/\/|.+)/.test(url)) {
url = this.options.url + (!this.options.url.endsWith('/') && url.startsWith('/') ? url : `/${url}`)
this.debug(`Changed URL to base url + relative path: ${url}`)
}
if (this.options.basicAuth && this.isAuthenticated !== true) {
if (url.includes(this.options.url)) {
await this.browserContext.setHTTPCredentials(this.options.basicAuth)
this.isAuthenticated = true
}
}
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 Playwright changes the size of a viewport, not the window!
* Playwright does not control the window of a browser, so it can't adjust its real size.
* It also can't maximize a window.
*
* Update configuration to change real window size on start:
*
* ```js
* // inside codecept.conf.js
* // @codeceptjs/configure package must be installed
* { setWindowSize } = require('@codeceptjs/configure');
* ````
*
* {{> resizeWindow }}
*/
async resizeWindow(width, height) {
if (width === 'maximize') {
throw new Error("Playwright can't control windows, so it can't maximize it")
}
await this.page.setViewportSize({ width, height })
return this._waitForAction()
}
/**
* Set headers for all next requests
*
* ```js
* I.setPlaywrightRequestHeaders({
* 'X-Sent-By': 'CodeceptJS',
* });
* ```
*
* @param {object} customHeaders headers to set
*/
async setPlaywrightRequestHeaders(customHeaders) {
if (!customHeaders) {
throw new Error('Cannot send empty headers.')
}
return this.browserContext.setExtraHTTPHeaders(customHeaders)
}
/**
* {{> moveCursorTo }}
*
*/
async moveCursorTo(locator, offsetX = 0, offsetY = 0) {
const el = await this._locateElement(locator)
assertElementExists(el, locator)
// Use manual mouse.move instead of .hover() so the offset can be added to the coordinates
const { x, y } = await clickablePoint(el)
await this.page.mouse.move(x + offsetX, y + offsetY)
return this._waitForAction()
}
/**
* {{> focus }}
*
*/
async focus(locator, options = {}) {
const el = await this._locateElement(locator)
assertElementExists(el, locator, 'Element to focus')
await el.focus(options)
return this._waitForAction()
}
/**
* {{> blur }}
*
*/
async blur(locator, options = {}) {
const el = await this._locateElement(locator)
assertElementExists(el, locator, 'Element to blur')
await el.blur(options)
return this._waitForAction()
}
/**
* Return the checked status of given element.
*
* @param {CodeceptJS.LocatorOrString} locator element located by CSS|XPath|strict locator.
* @param {object} [options] See https://playwright.dev/docs/api/class-locator#locator-is-checked
* @return {Promise<boolean>}
*
*/
async grabCheckedElementStatus(locator, options = {}) {
const supportedTypes = ['checkbox', 'radio']
const el = await this._locateElement(locator)
const type = await el.getAttribute('type')
if (supportedTypes.includes(type)) {
return el.isChecked(options)
}
throw new Error(`Element is not a ${supportedTypes.join(' or ')} input`)
}
/**
* Return the disabled status of given element.
*
* @param {CodeceptJS.LocatorOrString} locator element located by CSS|XPath|strict locator.
* @param {object} [options] See https://playwright.dev/docs/api/class-locator#locator-is-disabled
* @return {Promise<boolean>}
*
*/
async grabDisabledElementStatus(locator, options = {}) {
const el = await this._locateElement(locator)
return el.isDisabled(options)
}
/**
*
* ```js
* // specify coordinates for source position
* I.dragAndDrop('img.src', 'img.dst', { sourcePosition: {x: 10, y: 10} })
* ```
*
* > When no option is set, custom drag and drop would be used, to use the dragAndDrop API from Playwright, please set options, for example `force: true`
*
* {{> dragAndDrop }}
* @param {any} [options] [Additional options](https://playwright.dev/docs/api/class-page#page-drag-and-drop) can be passed as 3rd argument.
*
*/
async dragAndDrop(srcElement, destElement, options) {
const src = new Locator(srcElement)
const dst = new Locator(destElement)
if (options) {
return this.page.dragAndDrop(buildLocatorString(src), buildLocatorString(dst), options)
}
const _smallWaitInMs = 600
await this.page.locator(buildLocatorString(src)).hover()
await this.page.mouse.down()
await this.page.waitForTimeout(_smallWaitInMs)
const destElBox = await this.page.locator(buildLocatorString(dst)).boundingBox()
await this.page.mouse.move(destElBox.x + destElBox.width / 2, destElBox.y + destElBox.height / 2)
await this.page.locator(buildLocatorString(dst)).hover({ position: { x: 10, y: 10 } })
await this.page.waitForTimeout(_smallWaitInMs)
await this.page.mouse.up()
}
/**
* Restart browser with a new context and a new page
*
* ```js
* // Restart browser and use a new timezone
* I.restartBrowser({ timezoneId: 'America/Phoenix' });
* // Open URL in a new page in changed timezone
* I.amOnPage('/');
* // Restart browser, allow reading/copying of text from/into clipboard in Chrome
* I.restartBrowser({ permissions: ['clipboard-read', 'clipboard-write'] });
* ```
*
* @param {object} [contextOptions] [Options for browser context](https://playwright.dev/docs/api/class-browser#browser-new-context) when starting new browser
*/
async restartBrowser(contextOptions) {
await this._stopBrowser()
await this._startBrowser()
await this._createContextPage(contextOptions)
}
/**
* {{> refreshPage }}
*/
async refreshPage() {
return this.page.reload({ timeout: this.options.getPageTimeout, waitUntil: this.options.waitForNavigation })
}
/**
* Replaying from HAR
*
* ```js
* // Replay API requests from HAR.
* // Either use a matching response from the HAR,
* // or abort the request if nothing matches.
* I.replayFromHar('./output/har/something.har', { url: "*\/**\/api/v1/fruits" });
* I.amOnPage('https://demo.playwright.dev/api-mocking');
* I.see('CodeceptJS');
* ```
*
* @param {string} harFilePath Path to recorded HAR file
* @param {object} [opts] [Options for replaying from HAR](https://playwright.dev/docs/api/class-page#page-route-from-har)
*
* @returns Promise<void>
*/
async replayFromHar(harFilePath, opts) {
const file = path.join(global.codecept_dir, harFilePath)
if (!fileExists(file)) {
throw new Error(`File at ${file} cannot be found on local system`)
}
await this.page.routeFromHAR(harFilePath, opts)
}
/**
* {{> scrollPageToTop }}
*/
scrollPageToTop() {
return this.executeScript(() => {
window.scrollTo(0, 0)
})
}
/**
* {{> scrollPageToBottom }}
*/
async 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)
assertElementExists(el, locator, 'Element')
await el.scrollIntoViewIfNeeded()
const elementCoordinates = await clickablePoint(el)
await this.executeScript((offsetX, offsetY) => window.scrollBy(offsetX, offsetY), {
offsetX: elementCoordinates.x + offsetX,
offsetY: elementCoordinates.y + offsetY,
})
} else {
await this.executeScript(({ offsetX, offsetY }) => window.scrollTo(offsetX, offsetY), { 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['Playwright']._locate({name: 'password'});
* ```
*/
async _locate(locator) {
const context = await this._getContext()
if (this.frame) return findElements.call(this, this.frame, locator)
const els = await findElements.call(this, context, locator)
if (store.debugMode) {
const previewElements = els.slice(0, 3)
let htmls = await Promise.all(previewElements.map(el => elToString(el, previewElements.length)))
if (els.length > 3) htmls.push('...')
if (els