UNPKG

codeceptjs

Version:

Supercharged End 2 End Testing Framework for NodeJS

1,372 lines (1,257 loc) 159 kB
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