codeceptjs
Version:
Supercharged End 2 End Testing Framework for NodeJS
1,372 lines (1,257 loc) • 159 kB
JavaScript
import path from 'path'
import fs from 'fs'
import Helper from '@codeceptjs/helper'
import { v4 as uuidv4 } from 'uuid'
import assert from 'assert'
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 {
xpathLocator,
ucfirst,
fileExists,
chunkArray,
convertCssPropertiesToCamelCase,
screenshotOutputFolder,
getNormalizedKeyAttributeValue,
isModifierKey,
clearString,
requireWithFallback,
normalizeSpacesInString,
normalizePath,
resolveUrl,
relativeDir,
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 { findByPlaywrightLocator } from './extras/PlaywrightLocator.js'
import { dropFile } from './scripts/dropFile.js'
import WebElement from '../element/WebElement.js'
import { selectElement } from './extras/elementSelection.js'
import { fillRichEditor } from './extras/richTextEditor.js'
let playwright
let perfTiming
let defaultSelectorEnginesInitialized = false
const popupStore = new Popup()
const consoleLogStore = new Console()
const availableBrowsers = ['chromium', 'webkit', 'firefox', 'electron']
import { setRestartStrategy, restartsSession, restartsContext, restartsBrowser } from './extras/PlaywrightRestartOpts.js'
import { createValueEngine, createDisabledEngine } from './extras/PlaywrightPropEngine.js'
import { seeElementError, dontSeeElementError, dontSeeElementInDOMError, seeElementInDOMError } from './errors/ElementAssertion.js'
import { dontSeeTraffic, seeTraffic, grabRecordedNetworkTraffics, stopRecordingTraffic, flushNetworkTraffics } from './network/actions.js'
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.
* * '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 {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 will be loaded dynamically in _init method
// 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
// Add test failure tracking to prevent false positives
this.testFailures = []
this.hasCleanupError = false
// 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,
onResponse: null,
strict: false,
}
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 = `${store.outputDir}/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 {
// In ESM, playwright will be checked via dynamic import in constructor
// The import will fail at module load time if playwright is missing
return null
} catch (e) {
return ['playwright@^1.18']
}
}
async _init() {
// Load playwright dynamically with fallback
if (!playwright) {
try {
playwright = await import('playwright')
playwright = playwright.default || playwright
} catch (e) {
try {
playwright = await import('playwright-core')
playwright = playwright.default || playwright
} catch (e2) {
throw new Error('Neither playwright nor playwright-core could be loaded. Please install one of them.')
}
}
}
// register an internal selector engine for reading value property of elements in a selector
try {
// Always wrap in try-catch since selectors might be registered globally across workers
// Check global flag to avoid re-registration in worker processes
if (!defaultSelectorEnginesInitialized) {
try {
await playwright.selectors.register('__value', createValueEngine)
await playwright.selectors.register('__disabled', createDisabledEngine)
defaultSelectorEnginesInitialized = true
defaultSelectorEnginesInitialized = true
} catch (e) {
if (!e.message.includes('already registered')) {
throw e
}
// Selector already registered globally by another worker
defaultSelectorEnginesInitialized = true
defaultSelectorEnginesInitialized = true
}
} else {
// Selectors already registered in a worker, skip
defaultSelectorEnginesInitialized = true
this.debugSection('Init', 'Default selector engines already registered globally, skipping')
}
if (process.env.testIdAttribute) {
try {
await playwright.selectors.setTestIdAttribute(process.env.testIdAttribute)
} catch (e) {
// Ignore if already set
}
}
} catch (e) {
console.warn(e)
}
}
_beforeSuite() {
// Skip browser start in dry-run mode (used by check command)
if (store.dryRun) {
this.debugSection('Dry Run', 'Skipping browser start')
return
}
// Start browser if not manually started and not already running
// Browser should start in singleton mode (restart: false) or when restart strategy is enabled
if (!this.options.manualStart && !this.isRunning) {
this.debugSection('Session', 'Starting singleton browser session')
return this._startBrowser()
}
}
async _before(test) {
// Skip browser operations in dry-run mode (used by check command)
if (store.dryRun) {
this.currentRunningTest = test
return
}
this.currentRunningTest = test
// Reset failure tracking for each test to prevent false positives
this.hasCleanupError = false
this.testFailures = []
// Reset frame context to ensure clean state for each test
this.context = this.page
this.frame = null
this.contextLocator = null
// Clear popup state to ensure clean state for each test
popupStore.clear()
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')
},
})
// Start browser if needed (initial start or browser restart strategy)
if (!this.isRunning && !this.options.manualStart) await this._startBrowser()
else if (restartsBrowser() && !this.options.manualStart) {
// Browser restart strategy: start browser for each test
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 = `${`${store.outputDir}${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()) {
if (!this.browser) {
if (this.options.manualStart) {
this.debugSection('Manual Start', 'Browser not started - skipping context creation')
return // Skip context creation when manualStart is true
} else {
throw new Error('Browser not started. This should not happen.')
}
}
this.debugSection('New Session', JSON.stringify(this.contextOptions))
try {
this.browserContext = await this.browser.newContext(this.contextOptions) // Adding the HTTPSError ignore in the context so that we can ignore those errors
} catch (err) {
// In worker mode with Playwright 1.x, there's a known issue where newContext() fails
// with "selector engine already registered" when selectors are registered globally
// across worker threads. This is safe to retry without ANY custom options.
if (err.message && err.message.includes('already registered')) {
this.debugSection('Worker Mode', 'Selector conflict detected, retrying context creation with no options')
// Create context with NO options to avoid selector conflicts
this.browserContext = await this.browser.newContext()
} else {
throw err
}
}
}
}
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) {
try {
this.browser.close()
this.electronSessions.forEach(session => session.close())
} catch (e) {
console.warn('Warning during electron cleanup:', e.message)
}
return
}
if (restartsSession()) {
return refreshContextSession.bind(this)()
}
if (restartsBrowser()) {
// Close browser completely for restart strategy
if (this.isRunning) {
try {
// Close all pages first to release resources
if (this.browserContext) {
const pages = await this.browserContext.pages()
await Promise.allSettled(pages.map(p => p.close().catch(() => {})))
}
// Use timeout to prevent hanging (10s should be enough for browser cleanup)
await Promise.race([this._stopBrowser(), new Promise((_, reject) => setTimeout(() => reject(new Error('Browser stop timeout')), 10000))])
} catch (e) {
console.warn('Warning during browser restart in _after:', e.message)
// Force cleanup even on timeout
this.browser = null
this.browserContext = null
this.isRunning = false
}
}
return
}
// close other sessions with timeout protection, but only if restartsContext() is true
if (restartsContext()) {
try {
if ((await this.browser)?._type === 'Browser') {
const contexts = await Promise.race([this.browser.contexts(), new Promise((_, reject) => setTimeout(() => reject(new Error('Get contexts timeout')), 3000))])
const currentContext = contexts[0]
if (currentContext && (this.options.keepCookies || this.options.keepBrowserState)) {
try {
this.storageState = await currentContext.storageState()
} catch (e) {
console.warn('Warning during storage state save:', e.message)
}
}
await Promise.race([Promise.all(contexts.map(c => c.close())), new Promise((_, reject) => setTimeout(() => reject(new Error('Close contexts timeout')), 5000))])
}
} catch (e) {
console.warn('Warning during context cleanup in _after:', e.message)
}
}
return this.browser
}
async _afterSuite() {
// Reset leftover test-level cleanup state (e.g. screenshot failures)
// so only errors from this suite teardown are evaluated below.
this.hasCleanupError = false
this.testFailures = []
// Stop browser after suite completes
// For restart strategies: stop after each suite
// For session mode (restart:false): stop after the last suite
if (this.isRunning) {
try {
// Add timeout protection to prevent hanging
await Promise.race([this._stopBrowser(), new Promise((_, reject) => setTimeout(() => reject(new Error('Browser stop timeout in afterSuite')), 10000))])
} catch (e) {
console.warn('Warning during suite cleanup:', e.message)
// Track suite cleanup failures
this.hasCleanupError = true
this.testFailures.push(`Suite cleanup failed: ${e.message}`)
// Force cleanup on timeout
this.browser = null
this.browserContext = null
this.isRunning = false
} finally {
this.isRunning = false
}
}
// Force cleanup of any remaining browser processes
try {
if (this.browser && (!this.browser.isConnected || this.browser)) {
await Promise.race([Promise.resolve(), new Promise(resolve => setTimeout(resolve, 1000))])
}
} catch (e) {
console.warn('Final cleanup warning:', e.message)
this.hasCleanupError = true
this.testFailures.push(`Final cleanup failed: ${e.message}`)
}
// Clean up session pages explicitly to prevent hanging references
try {
if (this.sessionPages && Object.keys(this.sessionPages).length > 0) {
for (const sessionName in this.sessionPages) {
const sessionPage = this.sessionPages[sessionName]
if (sessionPage && !sessionPage.isClosed()) {
try {
// Remove any remaining event listeners from session pages
sessionPage.removeAllListeners('dialog')
sessionPage.removeAllListeners('crash')
sessionPage.removeAllListeners('close')
sessionPage.removeAllListeners('error')
await sessionPage.close()
} catch (e) {
console.warn(`Warning closing session page ${sessionName}:`, e.message)
}
}
}
this.sessionPages = {} // Clear the session pages object
this.activeSessionName = '' // Reset active session name
}
} catch (e) {
console.warn('Session pages cleanup warning:', e.message)
this.hasCleanupError = true
this.testFailures.push(`Session cleanup failed: ${e.message}`)
}
// Clear any lingering DOM timeouts by executing cleanup in browser context
try {
if (this.page && !this.page.isClosed()) {
await this.page
.evaluate(() => {
// Clear any running highlight timeouts by clearing a range of timeout IDs
for (let i = 1; i <= 1000; i++) {
clearTimeout(i)
}
})
.catch(() => {
// Ignore errors if execution context is destroyed (e.g., due to navigation)
})
}
} catch (e) {
// Only log if it's not an execution context error
if (!e.message.includes('Execution context was destroyed')) {
console.warn('DOM timeout cleanup warning:', e.message)
this.hasCleanupError = true
this.testFailures.push(`DOM cleanup failed: ${e.message}`)
}
}
// If we have cleanup errors, throw to fail the test suite
if (this.hasCleanupError && this.testFailures.length > 0) {
const errorMessage = `Test suite cleanup failed: ${this.testFailures.join('; ')}`
console.error(errorMessage)
throw new Error(errorMessage)
}
}
async _finishTest() {
if (this.isRunning) {
try {
await Promise.race([this._stopBrowser(), new Promise((_, reject) => setTimeout(() => reject(new Error('Test finish timeout')), 10000))])
} catch (e) {
console.warn('Warning during test finish cleanup:', e.message)
// Track cleanup failures to prevent false positives
this.hasCleanupError = true
this.testFailures.push(`Test finish cleanup failed: ${e.message}`)
this.isRunning = false
// Set flags to prevent further operations after cleanup failure
this.page = null
this.browserContext = null
this.browser = null
// Propagate the error to fail the test properly
throw new Error(`Test cleanup failed: ${e.message}`)
}
}
}
async _cleanup() {
// Final cleanup when test run completes
if (this.isRunning) {
try {
// Add timeout protection to prevent hanging
await Promise.race([this._stopBrowser(), new Promise((_, reject) => setTimeout(() => reject(new Error('Browser stop timeout in cleanup')), 10000))])
} catch (e) {
console.warn('Warning during final cleanup:', e.message)
// Force cleanup on timeout
this.browser = null
this.browserContext = null
this.isRunning = false
}
} else {
// Check if we still have a browser object despite isRunning being false
if (this.browser) {
try {
// Add timeout protection to prevent hanging
await Promise.race([this._stopBrowser(), new Promise((_, reject) => setTimeout(() => reject(new Error('Browser stop timeout in forced cleanup')), 10000))])
} catch (e) {
console.warn('Warning during forced cleanup:', e.message)
// Force cleanup on timeout
this.browser = null
this.browserContext = null
}
}
}
}
_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 {
// Check if browser is still available before creating context
if (!this.browser) {
throw new Error('Browser is not available for session context creation')
}
browserContext = await Promise.race([this.browser.newContext(Object.assign(this.contextOptions, config)), new Promise((_, reject) => setTimeout(() => reject(new Error('New context timeout')), 10000))])
page = await Promise.race([browserContext.newPage(), new Promise((_, reject) => setTimeout(() => reject(new Error('New page timeout')), 5000))])
} catch (e) {
console.warn('Warning during context creation:', e.message)
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]
} else {
throw e
}
}
}
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
}
// Safety check: ensure browserContext exists before calling pages()
if (!this.browserContext) {
this.debug('Cannot restore session vars: browserContext is undefined')
return
}
try {
const existingPages = await this.browserContext.pages()
if (existingPages && existingPages.length > 0) {
await this._setPage(existingPages[0])
// Reset context-related variables to ensure clean state after session
this.context = await this.page
this.contextLocator = null
this.frame = 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 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) {
// Clean up previous page event listeners
if (this.page && this.page !== page) {
try {
this.page.removeAllListeners('crash')
this.page.removeAllListeners('dialog')
this.page.removeAllListeners('load')
this.page.removeAllListeners('console')
this.page.removeAllListeners('requestfinished')
} catch (e) {
console.warn('Warning cleaning previous page listeners:', e.message)
}
}
page = await page
this._addPopupListener(page)
this.page = page
if (!page) return
try {
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!')
try {
await page.close()
} catch (e) {
console.warn('Warning during crashed page cleanup:', e.message)
}
})
this.context = await this.page
this.contextLocator = null
await page.bringToFront()
} catch (e) {
console.warn('Warning during page setup:', e.message)
this.context = await this.page
this.contextLocator = null
}
}
/**
* 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
// Only init once globally to avoid selector re-registration in workers
if (!defaultSelectorEnginesInitialized) {
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
}
/**
* 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) {
if (!this.browser) {
throw new Error('Browser not started. Call _startBrowser() first or disable manualStart option.')
}
this.browserContext = await this.browser.newContext(contextOptions)
const page = await this.browserContext.newPage()
targetCreatedHandler.call(this, page)
await this._setPage(page)
}
_getType() {
return this.browser._type
}
async _stopBrowser() {
this.withinLocator = null
await this._setPage(null)
this.context = null
this.frame = null
popupStore.clear()
// Remove all event listeners to prevent hanging
if (this.browser) {
try {
this.browser.removeAllListeners()
} catch (e) {
// Ignore errors if browser is already closed
}
}
// Close browserContext if recordHar is enabled
if (this.options.recordHar && this.browserContext) {
try {
await this.browserContext.close()
} catch (e) {
// Ignore errors if context is already closed
}
}
this.browserContext = null
// Initiate browser close without waiting for it to complete
// The browser process will be cleaned up when the Node process exits
if (this.browser) {
try {
// Fire and forget - don't wait for close to complete
this.browser.close().catch(() => {
// Silently ignore any errors during async close
})
} catch (e) {
// Ignore any synchronous errors
}
}
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)) {
// For nested frames, build the complete frame path
await this.switchTo(null)
// Build nested frame locator from page
let frameLocatorObj = this.page
for (const frameSelector of frame) {
const selector = buildLocatorString(new Locator(frameSelector, 'css'))
frameLocatorObj = frameLocatorObj.frameLocator(selector)
}
this.frame = frameLocatorObj
this.context = frameLocatorObj
this.contextLocator = null
this.withinLocator = new Locator(frame)
return
}
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
if (this.page) {
this.context = await this.page
} else {
this.context = null
}
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')
}
// Prevent navigation attempts only when manual start is enabled and browser is not running
// Allow auto-initialization for normal operation (e.g., when using BROWSER_RESTART=browser)
if (!this.isRunning && this.options.manualStart && (!this.browser || !this.browserContext || !this.page)) {
throw new Error('Cannot navigate: browser is not running or has been closed')
}
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
}
}
// Ensure browser is initialized before page operations
if (!this.page) {
this.debugSection('Auto-initializing', `Browser not started properly. page=${!!this.page}, isRunning=${this.isRunning}, browser=${!!this.browser}, browserContext=${!!this.browserContext}`)
if (!this.browser) {
await this._startBrowser()
}
// Create browser context and page (simplified version of _before logic)
if (!this.browserContext) {
if (!this.browser) {
throw