codeceptjs 3.7.6-beta.4 → 4.0.0-beta.10.esm-aria
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -3
- package/bin/codecept.js +51 -53
- package/bin/test-server.js +14 -3
- package/docs/webapi/click.mustache +5 -1
- package/lib/actor.js +15 -11
- package/lib/ai.js +72 -107
- package/lib/assert/empty.js +9 -8
- package/lib/assert/equal.js +15 -17
- package/lib/assert/error.js +2 -2
- package/lib/assert/include.js +9 -11
- package/lib/assert/throws.js +1 -1
- package/lib/assert/truth.js +8 -5
- package/lib/assert.js +18 -18
- package/lib/codecept.js +102 -75
- package/lib/colorUtils.js +48 -50
- package/lib/command/check.js +32 -27
- package/lib/command/configMigrate.js +11 -10
- package/lib/command/definitions.js +16 -10
- package/lib/command/dryRun.js +16 -16
- package/lib/command/generate.js +62 -27
- package/lib/command/gherkin/init.js +36 -38
- package/lib/command/gherkin/snippets.js +14 -14
- package/lib/command/gherkin/steps.js +21 -18
- package/lib/command/info.js +8 -8
- package/lib/command/init.js +36 -29
- package/lib/command/interactive.js +11 -10
- package/lib/command/list.js +10 -9
- package/lib/command/run-multiple/chunk.js +5 -5
- package/lib/command/run-multiple/collection.js +5 -5
- package/lib/command/run-multiple/run.js +3 -3
- package/lib/command/run-multiple.js +16 -13
- package/lib/command/run-rerun.js +6 -7
- package/lib/command/run-workers.js +24 -9
- package/lib/command/run.js +23 -8
- package/lib/command/utils.js +20 -18
- package/lib/command/workers/runTests.js +197 -114
- package/lib/config.js +124 -51
- package/lib/container.js +438 -87
- package/lib/data/context.js +6 -5
- package/lib/data/dataScenarioConfig.js +1 -1
- package/lib/data/dataTableArgument.js +1 -1
- package/lib/data/table.js +1 -1
- package/lib/effects.js +94 -10
- package/lib/element/WebElement.js +2 -2
- package/lib/els.js +11 -9
- package/lib/event.js +11 -10
- package/lib/globals.js +141 -0
- package/lib/heal.js +12 -12
- package/lib/helper/AI.js +11 -11
- package/lib/helper/ApiDataFactory.js +50 -19
- package/lib/helper/Appium.js +19 -27
- package/lib/helper/FileSystem.js +32 -12
- package/lib/helper/GraphQL.js +3 -3
- package/lib/helper/GraphQLDataFactory.js +4 -4
- package/lib/helper/JSONResponse.js +25 -29
- package/lib/helper/Mochawesome.js +7 -4
- package/lib/helper/Playwright.js +902 -164
- package/lib/helper/Puppeteer.js +383 -76
- package/lib/helper/REST.js +29 -12
- package/lib/helper/WebDriver.js +268 -61
- package/lib/helper/clientscripts/PollyWebDriverExt.js +1 -1
- package/lib/helper/errors/ConnectionRefused.js +6 -6
- package/lib/helper/errors/ElementAssertion.js +11 -16
- package/lib/helper/errors/ElementNotFound.js +5 -9
- package/lib/helper/errors/RemoteBrowserConnectionRefused.js +5 -5
- package/lib/helper/extras/Console.js +11 -11
- package/lib/helper/extras/PlaywrightLocator.js +110 -0
- package/lib/helper/extras/PlaywrightPropEngine.js +18 -18
- package/lib/helper/extras/PlaywrightReactVueLocator.js +18 -9
- package/lib/helper/extras/PlaywrightRestartOpts.js +34 -23
- package/lib/helper/extras/Popup.js +1 -1
- package/lib/helper/extras/React.js +29 -30
- package/lib/helper/network/actions.js +29 -44
- package/lib/helper/network/utils.js +76 -83
- package/lib/helper/scripts/blurElement.js +6 -6
- package/lib/helper/scripts/focusElement.js +6 -6
- package/lib/helper/scripts/highlightElement.js +9 -9
- package/lib/helper/scripts/isElementClickable.js +34 -34
- package/lib/helper.js +2 -1
- package/lib/history.js +23 -20
- package/lib/hooks.js +10 -10
- package/lib/html.js +90 -100
- package/lib/index.js +48 -21
- package/lib/listener/config.js +19 -12
- package/lib/listener/emptyRun.js +6 -7
- package/lib/listener/enhancedGlobalRetry.js +6 -6
- package/lib/listener/exit.js +4 -3
- package/lib/listener/globalRetry.js +5 -5
- package/lib/listener/globalTimeout.js +30 -14
- package/lib/listener/helpers.js +39 -14
- package/lib/listener/mocha.js +3 -4
- package/lib/listener/result.js +4 -5
- package/lib/listener/retryEnhancer.js +3 -3
- package/lib/listener/steps.js +8 -7
- package/lib/listener/store.js +3 -3
- package/lib/locator.js +213 -192
- package/lib/mocha/asyncWrapper.js +105 -62
- package/lib/mocha/bdd.js +99 -13
- package/lib/mocha/cli.js +59 -26
- package/lib/mocha/factory.js +78 -19
- package/lib/mocha/featureConfig.js +1 -1
- package/lib/mocha/gherkin.js +56 -24
- package/lib/mocha/hooks.js +12 -3
- package/lib/mocha/index.js +13 -4
- package/lib/mocha/inject.js +22 -5
- package/lib/mocha/scenarioConfig.js +2 -2
- package/lib/mocha/suite.js +9 -2
- package/lib/mocha/test.js +10 -7
- package/lib/mocha/ui.js +28 -18
- package/lib/output.js +10 -8
- package/lib/parser.js +44 -44
- package/lib/pause.js +15 -16
- package/lib/plugin/analyze.js +19 -12
- package/lib/plugin/auth.js +20 -21
- package/lib/plugin/autoDelay.js +12 -8
- package/lib/plugin/coverage.js +28 -11
- package/lib/plugin/customLocator.js +3 -3
- package/lib/plugin/customReporter.js +3 -2
- package/lib/plugin/enhancedRetryFailedStep.js +6 -6
- package/lib/plugin/heal.js +14 -9
- package/lib/plugin/htmlReporter.js +724 -99
- package/lib/plugin/pageInfo.js +10 -10
- package/lib/plugin/pauseOnFail.js +4 -3
- package/lib/plugin/retryFailedStep.js +48 -5
- package/lib/plugin/screenshotOnFail.js +75 -37
- package/lib/plugin/stepByStepReport.js +14 -14
- package/lib/plugin/stepTimeout.js +4 -3
- package/lib/plugin/subtitles.js +6 -5
- package/lib/recorder.js +33 -14
- package/lib/rerun.js +69 -26
- package/lib/result.js +4 -4
- package/lib/retryCoordinator.js +2 -2
- package/lib/secret.js +18 -17
- package/lib/session.js +95 -89
- package/lib/step/base.js +7 -7
- package/lib/step/comment.js +2 -2
- package/lib/step/config.js +1 -1
- package/lib/step/func.js +3 -3
- package/lib/step/helper.js +3 -3
- package/lib/step/meta.js +5 -5
- package/lib/step/record.js +11 -11
- package/lib/step/retry.js +3 -3
- package/lib/step/section.js +3 -3
- package/lib/step.js +7 -10
- package/lib/steps.js +9 -5
- package/lib/store.js +1 -1
- package/lib/template/heal.js +1 -1
- package/lib/template/prompts/generatePageObject.js +31 -0
- package/lib/template/prompts/healStep.js +13 -0
- package/lib/template/prompts/writeStep.js +9 -0
- package/lib/test-server.js +17 -6
- package/lib/timeout.js +1 -7
- package/lib/transform.js +8 -8
- package/lib/translation.js +32 -18
- package/lib/utils/mask_data.js +4 -10
- package/lib/utils.js +66 -64
- package/lib/workerStorage.js +17 -17
- package/lib/workers.js +214 -84
- package/package.json +41 -37
- package/translations/de-DE.js +2 -2
- package/translations/fr-FR.js +2 -2
- package/translations/index.js +23 -10
- package/translations/it-IT.js +2 -2
- package/translations/ja-JP.js +2 -2
- package/translations/nl-NL.js +2 -2
- package/translations/pl-PL.js +2 -2
- package/translations/pt-BR.js +2 -2
- package/translations/ru-RU.js +2 -2
- package/translations/utils.js +4 -3
- package/translations/zh-CN.js +2 -2
- package/translations/zh-TW.js +2 -2
- package/typings/index.d.ts +5 -3
- package/typings/promiseBasedTypes.d.ts +4 -0
- package/typings/types.d.ts +4 -0
- package/lib/helper/Nightmare.js +0 -1486
- package/lib/helper/Protractor.js +0 -1840
- package/lib/helper/TestCafe.js +0 -1391
- package/lib/helper/clientscripts/nightmare.js +0 -213
- package/lib/helper/testcafe/testControllerHolder.js +0 -42
- package/lib/helper/testcafe/testcafe-utils.js +0 -61
- package/lib/plugin/allure.js +0 -15
- package/lib/plugin/autoLogin.js +0 -5
- package/lib/plugin/commentStep.js +0 -141
- package/lib/plugin/eachElement.js +0 -127
- package/lib/plugin/fakerTransform.js +0 -49
- package/lib/plugin/retryTo.js +0 -16
- package/lib/plugin/selenoid.js +0 -364
- package/lib/plugin/standardActingHelpers.js +0 -6
- package/lib/plugin/tryTo.js +0 -16
- package/lib/plugin/wdio.js +0 -247
- package/lib/within.js +0 -90
package/lib/helper/Playwright.js
CHANGED
|
@@ -1,19 +1,17 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
const { truth } = require('../assert/truth')
|
|
16
|
-
const {
|
|
1
|
+
import path from 'path'
|
|
2
|
+
import fs from 'fs'
|
|
3
|
+
import Helper from '@codeceptjs/helper'
|
|
4
|
+
import { v4 as uuidv4 } from 'uuid'
|
|
5
|
+
import assert from 'assert'
|
|
6
|
+
import promiseRetry from 'promise-retry'
|
|
7
|
+
import Locator from '../locator.js'
|
|
8
|
+
import recorder from '../recorder.js'
|
|
9
|
+
import store from '../store.js'
|
|
10
|
+
import { includes as stringIncludes } from '../assert/include.js'
|
|
11
|
+
import { urlEquals, equals } from '../assert/equal.js'
|
|
12
|
+
import { empty } from '../assert/empty.js'
|
|
13
|
+
import { truth } from '../assert/truth.js'
|
|
14
|
+
import {
|
|
17
15
|
xpathLocator,
|
|
18
16
|
ucfirst,
|
|
19
17
|
fileExists,
|
|
@@ -26,14 +24,14 @@ const {
|
|
|
26
24
|
requireWithFallback,
|
|
27
25
|
normalizeSpacesInString,
|
|
28
26
|
relativeDir,
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
27
|
+
} from '../utils.js'
|
|
28
|
+
import { isColorProperty, convertColorToRGBA } from '../colorUtils.js'
|
|
29
|
+
import ElementNotFound from './errors/ElementNotFound.js'
|
|
30
|
+
import RemoteBrowserConnectionRefused from './errors/RemoteBrowserConnectionRefused.js'
|
|
31
|
+
import Popup from './extras/Popup.js'
|
|
32
|
+
import Console from './extras/Console.js'
|
|
33
|
+
import { findReact, findVue, findByPlaywrightLocator } from './extras/PlaywrightReactVueLocator.js'
|
|
34
|
+
import WebElement from '../element/WebElement.js'
|
|
37
35
|
|
|
38
36
|
let playwright
|
|
39
37
|
let perfTiming
|
|
@@ -41,14 +39,19 @@ let defaultSelectorEnginesInitialized = false
|
|
|
41
39
|
let registeredCustomLocatorStrategies = new Set()
|
|
42
40
|
let globalCustomLocatorStrategies = new Map()
|
|
43
41
|
|
|
42
|
+
// Use global object to track selector registration across workers
|
|
43
|
+
if (typeof global.__playwrightSelectorsRegistered === 'undefined') {
|
|
44
|
+
global.__playwrightSelectorsRegistered = false
|
|
45
|
+
}
|
|
46
|
+
|
|
44
47
|
const popupStore = new Popup()
|
|
45
48
|
const consoleLogStore = new Console()
|
|
46
49
|
const availableBrowsers = ['chromium', 'webkit', 'firefox', 'electron']
|
|
47
50
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
51
|
+
import { setRestartStrategy, restartsSession, restartsContext, restartsBrowser } from './extras/PlaywrightRestartOpts.js'
|
|
52
|
+
import { createValueEngine, createDisabledEngine } from './extras/PlaywrightPropEngine.js'
|
|
53
|
+
import { seeElementError, dontSeeElementError, dontSeeElementInDOMError, seeElementInDOMError } from './errors/ElementAssertion.js'
|
|
54
|
+
import { dontSeeTraffic, seeTraffic, grabRecordedNetworkTraffics, stopRecordingTraffic, flushNetworkTraffics } from './network/actions.js'
|
|
52
55
|
|
|
53
56
|
const pathSeparator = path.sep
|
|
54
57
|
|
|
@@ -64,7 +67,6 @@ const pathSeparator = path.sep
|
|
|
64
67
|
* @prop {boolean} [show=true] - show browser window.
|
|
65
68
|
* @prop {string|boolean} [restart=false] - restart strategy between tests. Possible values:
|
|
66
69
|
* * '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.
|
|
67
|
-
* * 'browser' or **true** - closes browser and opens it again between tests.
|
|
68
70
|
* * '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
|
|
69
71
|
* @prop {number} [timeout=1000] - - [timeout](https://playwright.dev/docs/api/class-page#page-set-default-timeout) in ms of all Playwright actions .
|
|
70
72
|
* @prop {boolean} [disableScreenshots=false] - don't save screenshot on failure.
|
|
@@ -330,7 +332,7 @@ class Playwright extends Helper {
|
|
|
330
332
|
constructor(config) {
|
|
331
333
|
super(config)
|
|
332
334
|
|
|
333
|
-
playwright
|
|
335
|
+
// playwright will be loaded dynamically in _init method
|
|
334
336
|
|
|
335
337
|
// set defaults
|
|
336
338
|
this.isRemoteBrowser = false
|
|
@@ -353,7 +355,20 @@ class Playwright extends Helper {
|
|
|
353
355
|
this.recordingWebSocketMessages = false
|
|
354
356
|
this.recordedWebSocketMessagesAtLeastOnce = false
|
|
355
357
|
this.cdpSession = null
|
|
356
|
-
|
|
358
|
+
|
|
359
|
+
// Filter out invalid customLocatorStrategies (empty arrays, objects without functions)
|
|
360
|
+
// This can happen in worker threads where config is serialized/deserialized
|
|
361
|
+
let validCustomLocators = null
|
|
362
|
+
if (typeof config.customLocatorStrategies === 'object' && config.customLocatorStrategies !== null) {
|
|
363
|
+
// Check if it's an empty array or object with no function properties
|
|
364
|
+
const entries = Object.entries(config.customLocatorStrategies)
|
|
365
|
+
const hasFunctions = entries.some(([_, value]) => typeof value === 'function')
|
|
366
|
+
if (hasFunctions) {
|
|
367
|
+
validCustomLocators = config.customLocatorStrategies
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
this.customLocatorStrategies = validCustomLocators
|
|
357
372
|
this._customLocatorsRegistered = false
|
|
358
373
|
|
|
359
374
|
// Add custom locator strategies to global registry for early registration
|
|
@@ -363,6 +378,10 @@ class Playwright extends Helper {
|
|
|
363
378
|
}
|
|
364
379
|
}
|
|
365
380
|
|
|
381
|
+
// Add test failure tracking to prevent false positives
|
|
382
|
+
this.testFailures = []
|
|
383
|
+
this.hasCleanupError = false
|
|
384
|
+
|
|
366
385
|
// override defaults with config
|
|
367
386
|
this._setConfig(config)
|
|
368
387
|
|
|
@@ -479,20 +498,69 @@ class Playwright extends Helper {
|
|
|
479
498
|
|
|
480
499
|
static _checkRequirements() {
|
|
481
500
|
try {
|
|
482
|
-
|
|
501
|
+
// In ESM, playwright will be checked via dynamic import in constructor
|
|
502
|
+
// The import will fail at module load time if playwright is missing
|
|
503
|
+
return null
|
|
483
504
|
} catch (e) {
|
|
484
505
|
return ['playwright@^1.18']
|
|
485
506
|
}
|
|
486
507
|
}
|
|
487
508
|
|
|
488
509
|
async _init() {
|
|
510
|
+
// Load playwright dynamically with fallback
|
|
511
|
+
if (!playwright) {
|
|
512
|
+
try {
|
|
513
|
+
playwright = await import('playwright')
|
|
514
|
+
playwright = playwright.default || playwright
|
|
515
|
+
} catch (e) {
|
|
516
|
+
try {
|
|
517
|
+
playwright = await import('playwright-core')
|
|
518
|
+
playwright = playwright.default || playwright
|
|
519
|
+
} catch (e2) {
|
|
520
|
+
throw new Error('Neither playwright nor playwright-core could be loaded. Please install one of them.')
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// Ensure custom locators from this instance are in the global registry
|
|
526
|
+
// This is critical for worker threads where globalCustomLocatorStrategies is a new Map
|
|
527
|
+
if (this.customLocatorStrategies) {
|
|
528
|
+
for (const [strategyName, strategyFunction] of Object.entries(this.customLocatorStrategies)) {
|
|
529
|
+
if (!globalCustomLocatorStrategies.has(strategyName)) {
|
|
530
|
+
globalCustomLocatorStrategies.set(strategyName, strategyFunction)
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
489
535
|
// register an internal selector engine for reading value property of elements in a selector
|
|
490
536
|
try {
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
537
|
+
// Always wrap in try-catch since selectors might be registered globally across workers
|
|
538
|
+
// Check global flag to avoid re-registration in worker processes
|
|
539
|
+
if (!global.__playwrightSelectorsRegistered) {
|
|
540
|
+
try {
|
|
541
|
+
await playwright.selectors.register('__value', createValueEngine)
|
|
542
|
+
await playwright.selectors.register('__disabled', createDisabledEngine)
|
|
543
|
+
global.__playwrightSelectorsRegistered = true
|
|
544
|
+
defaultSelectorEnginesInitialized = true
|
|
545
|
+
} catch (e) {
|
|
546
|
+
if (!e.message.includes('already registered')) {
|
|
547
|
+
throw e
|
|
548
|
+
}
|
|
549
|
+
// Selector already registered globally by another worker
|
|
550
|
+
global.__playwrightSelectorsRegistered = true
|
|
551
|
+
defaultSelectorEnginesInitialized = true
|
|
552
|
+
}
|
|
553
|
+
} else {
|
|
554
|
+
// Selectors already registered in a worker, skip
|
|
495
555
|
defaultSelectorEnginesInitialized = true
|
|
556
|
+
this.debugSection('Init', 'Default selector engines already registered globally, skipping')
|
|
557
|
+
}
|
|
558
|
+
if (process.env.testIdAttribute) {
|
|
559
|
+
try {
|
|
560
|
+
await playwright.selectors.setTestIdAttribute(process.env.testIdAttribute)
|
|
561
|
+
} catch (e) {
|
|
562
|
+
// Ignore if already set
|
|
563
|
+
}
|
|
496
564
|
}
|
|
497
565
|
|
|
498
566
|
// Register all custom locator strategies from the global registry
|
|
@@ -548,15 +616,41 @@ class Playwright extends Helper {
|
|
|
548
616
|
}
|
|
549
617
|
|
|
550
618
|
_beforeSuite() {
|
|
551
|
-
|
|
619
|
+
// Skip browser start in dry-run mode (used by check command)
|
|
620
|
+
if (store.dryRun) {
|
|
621
|
+
this.debugSection('Dry Run', 'Skipping browser start')
|
|
622
|
+
return
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// Start browser if not manually started and not already running
|
|
626
|
+
// Browser should start in singleton mode (restart: false) or when restart strategy is enabled
|
|
627
|
+
if (!this.options.manualStart && !this.isRunning) {
|
|
552
628
|
this.debugSection('Session', 'Starting singleton browser session')
|
|
553
629
|
return this._startBrowser()
|
|
554
630
|
}
|
|
555
631
|
}
|
|
556
632
|
|
|
557
633
|
async _before(test) {
|
|
634
|
+
// Skip browser operations in dry-run mode (used by check command)
|
|
635
|
+
if (store.dryRun) {
|
|
636
|
+
this.currentRunningTest = test
|
|
637
|
+
return
|
|
638
|
+
}
|
|
639
|
+
|
|
558
640
|
this.currentRunningTest = test
|
|
559
641
|
|
|
642
|
+
// Reset failure tracking for each test to prevent false positives
|
|
643
|
+
this.hasCleanupError = false
|
|
644
|
+
this.testFailures = []
|
|
645
|
+
|
|
646
|
+
// Reset frame context to ensure clean state for each test
|
|
647
|
+
this.context = this.page
|
|
648
|
+
this.frame = null
|
|
649
|
+
this.contextLocator = null
|
|
650
|
+
|
|
651
|
+
// Clear popup state to ensure clean state for each test
|
|
652
|
+
popupStore.clear()
|
|
653
|
+
|
|
560
654
|
recorder.retry({
|
|
561
655
|
retries: test?.opts?.conditionalRetries || 3,
|
|
562
656
|
when: err => {
|
|
@@ -568,8 +662,12 @@ class Playwright extends Helper {
|
|
|
568
662
|
},
|
|
569
663
|
})
|
|
570
664
|
|
|
571
|
-
if (
|
|
665
|
+
// Start browser if needed (initial start or browser restart strategy)
|
|
572
666
|
if (!this.isRunning && !this.options.manualStart) await this._startBrowser()
|
|
667
|
+
else if (restartsBrowser() && !this.options.manualStart) {
|
|
668
|
+
// Browser restart strategy: start browser for each test
|
|
669
|
+
await this._startBrowser()
|
|
670
|
+
}
|
|
573
671
|
|
|
574
672
|
this.isAuthenticated = false
|
|
575
673
|
if (this.isElectron) {
|
|
@@ -606,8 +704,29 @@ class Playwright extends Helper {
|
|
|
606
704
|
if (this.options.colorScheme) contextOptions.colorScheme = this.options.colorScheme
|
|
607
705
|
this.contextOptions = contextOptions
|
|
608
706
|
if (!this.browserContext || !restartsSession()) {
|
|
707
|
+
if (!this.browser) {
|
|
708
|
+
if (this.options.manualStart) {
|
|
709
|
+
this.debugSection('Manual Start', 'Browser not started - skipping context creation')
|
|
710
|
+
return // Skip context creation when manualStart is true
|
|
711
|
+
} else {
|
|
712
|
+
throw new Error('Browser not started. This should not happen.')
|
|
713
|
+
}
|
|
714
|
+
}
|
|
609
715
|
this.debugSection('New Session', JSON.stringify(this.contextOptions))
|
|
610
|
-
|
|
716
|
+
try {
|
|
717
|
+
this.browserContext = await this.browser.newContext(this.contextOptions) // Adding the HTTPSError ignore in the context so that we can ignore those errors
|
|
718
|
+
} catch (err) {
|
|
719
|
+
// In worker mode with Playwright 1.x, there's a known issue where newContext() fails
|
|
720
|
+
// with "selector engine already registered" when selectors are registered globally
|
|
721
|
+
// across worker threads. This is safe to retry without ANY custom options.
|
|
722
|
+
if (err.message && err.message.includes('already registered')) {
|
|
723
|
+
this.debugSection('Worker Mode', 'Selector conflict detected, retrying context creation with no options')
|
|
724
|
+
// Create context with NO options to avoid selector conflicts
|
|
725
|
+
this.browserContext = await this.browser.newContext()
|
|
726
|
+
} else {
|
|
727
|
+
throw err
|
|
728
|
+
}
|
|
729
|
+
}
|
|
611
730
|
}
|
|
612
731
|
}
|
|
613
732
|
|
|
@@ -652,8 +771,12 @@ class Playwright extends Helper {
|
|
|
652
771
|
popupStore.clear()
|
|
653
772
|
|
|
654
773
|
if (this.isElectron) {
|
|
655
|
-
|
|
656
|
-
|
|
774
|
+
try {
|
|
775
|
+
this.browser.close()
|
|
776
|
+
this.electronSessions.forEach(session => session.close())
|
|
777
|
+
} catch (e) {
|
|
778
|
+
console.warn('Warning during electron cleanup:', e.message)
|
|
779
|
+
}
|
|
657
780
|
return
|
|
658
781
|
}
|
|
659
782
|
|
|
@@ -662,33 +785,203 @@ class Playwright extends Helper {
|
|
|
662
785
|
}
|
|
663
786
|
|
|
664
787
|
if (restartsBrowser()) {
|
|
665
|
-
|
|
666
|
-
|
|
788
|
+
// Close browser completely for restart strategy
|
|
789
|
+
if (this.isRunning) {
|
|
790
|
+
try {
|
|
791
|
+
// Close all pages first to release resources
|
|
792
|
+
if (this.browserContext) {
|
|
793
|
+
const pages = await this.browserContext.pages()
|
|
794
|
+
await Promise.allSettled(pages.map(p => p.close().catch(() => {})))
|
|
795
|
+
}
|
|
796
|
+
// Use timeout to prevent hanging (10s should be enough for browser cleanup)
|
|
797
|
+
await Promise.race([
|
|
798
|
+
this._stopBrowser(),
|
|
799
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('Browser stop timeout')), 10000)),
|
|
800
|
+
])
|
|
801
|
+
} catch (e) {
|
|
802
|
+
console.warn('Warning during browser restart in _after:', e.message)
|
|
803
|
+
// Force cleanup even on timeout
|
|
804
|
+
this.browser = null
|
|
805
|
+
this.browserContext = null
|
|
806
|
+
this.isRunning = false
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
return
|
|
667
810
|
}
|
|
668
811
|
|
|
669
|
-
// close other sessions
|
|
812
|
+
// close other sessions with timeout protection, but only if restartsContext() is true
|
|
813
|
+
if (restartsContext()) {
|
|
814
|
+
try {
|
|
815
|
+
if ((await this.browser)?._type === 'Browser') {
|
|
816
|
+
const contexts = await Promise.race([this.browser.contexts(), new Promise((_, reject) => setTimeout(() => reject(new Error('Get contexts timeout')), 3000))])
|
|
817
|
+
const currentContext = contexts[0]
|
|
818
|
+
if (currentContext && (this.options.keepCookies || this.options.keepBrowserState)) {
|
|
819
|
+
try {
|
|
820
|
+
this.storageState = await currentContext.storageState()
|
|
821
|
+
} catch (e) {
|
|
822
|
+
console.warn('Warning during storage state save:', e.message)
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
await Promise.race([Promise.all(contexts.map(c => c.close())), new Promise((_, reject) => setTimeout(() => reject(new Error('Close contexts timeout')), 5000))])
|
|
827
|
+
}
|
|
828
|
+
} catch (e) {
|
|
829
|
+
console.warn('Warning during context cleanup in _after:', e.message)
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
return this.browser
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
async _afterSuite() {
|
|
837
|
+
// Stop browser after suite completes
|
|
838
|
+
// For restart strategies: stop after each suite
|
|
839
|
+
// For session mode (restart:false): stop after the last suite
|
|
840
|
+
if (this.isRunning) {
|
|
841
|
+
try {
|
|
842
|
+
// Add timeout protection to prevent hanging
|
|
843
|
+
await Promise.race([
|
|
844
|
+
this._stopBrowser(),
|
|
845
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('Browser stop timeout in afterSuite')), 10000)),
|
|
846
|
+
])
|
|
847
|
+
} catch (e) {
|
|
848
|
+
console.warn('Warning during suite cleanup:', e.message)
|
|
849
|
+
// Track suite cleanup failures
|
|
850
|
+
this.hasCleanupError = true
|
|
851
|
+
this.testFailures.push(`Suite cleanup failed: ${e.message}`)
|
|
852
|
+
// Force cleanup on timeout
|
|
853
|
+
this.browser = null
|
|
854
|
+
this.browserContext = null
|
|
855
|
+
this.isRunning = false
|
|
856
|
+
} finally {
|
|
857
|
+
this.isRunning = false
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// Force cleanup of any remaining browser processes
|
|
862
|
+
try {
|
|
863
|
+
if (this.browser && (!this.browser.isConnected || this.browser)) {
|
|
864
|
+
await Promise.race([Promise.resolve(), new Promise(resolve => setTimeout(resolve, 1000))])
|
|
865
|
+
}
|
|
866
|
+
} catch (e) {
|
|
867
|
+
console.warn('Final cleanup warning:', e.message)
|
|
868
|
+
this.hasCleanupError = true
|
|
869
|
+
this.testFailures.push(`Final cleanup failed: ${e.message}`)
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// Clean up session pages explicitly to prevent hanging references
|
|
670
873
|
try {
|
|
671
|
-
if ((
|
|
672
|
-
const
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
874
|
+
if (this.sessionPages && Object.keys(this.sessionPages).length > 0) {
|
|
875
|
+
for (const sessionName in this.sessionPages) {
|
|
876
|
+
const sessionPage = this.sessionPages[sessionName]
|
|
877
|
+
if (sessionPage && !sessionPage.isClosed()) {
|
|
878
|
+
try {
|
|
879
|
+
// Remove any remaining event listeners from session pages
|
|
880
|
+
sessionPage.removeAllListeners('dialog')
|
|
881
|
+
sessionPage.removeAllListeners('crash')
|
|
882
|
+
sessionPage.removeAllListeners('close')
|
|
883
|
+
sessionPage.removeAllListeners('error')
|
|
884
|
+
await sessionPage.close()
|
|
885
|
+
} catch (e) {
|
|
886
|
+
console.warn(`Warning closing session page ${sessionName}:`, e.message)
|
|
887
|
+
}
|
|
888
|
+
}
|
|
676
889
|
}
|
|
890
|
+
this.sessionPages = {} // Clear the session pages object
|
|
891
|
+
this.activeSessionName = '' // Reset active session name
|
|
892
|
+
}
|
|
893
|
+
} catch (e) {
|
|
894
|
+
console.warn('Session pages cleanup warning:', e.message)
|
|
895
|
+
this.hasCleanupError = true
|
|
896
|
+
this.testFailures.push(`Session cleanup failed: ${e.message}`)
|
|
897
|
+
}
|
|
677
898
|
|
|
678
|
-
|
|
899
|
+
// Clear any lingering DOM timeouts by executing cleanup in browser context
|
|
900
|
+
try {
|
|
901
|
+
if (this.page && !this.page.isClosed()) {
|
|
902
|
+
await this.page
|
|
903
|
+
.evaluate(() => {
|
|
904
|
+
// Clear any running highlight timeouts by clearing a range of timeout IDs
|
|
905
|
+
for (let i = 1; i <= 1000; i++) {
|
|
906
|
+
clearTimeout(i)
|
|
907
|
+
}
|
|
908
|
+
})
|
|
909
|
+
.catch(() => {
|
|
910
|
+
// Ignore errors if execution context is destroyed (e.g., due to navigation)
|
|
911
|
+
})
|
|
679
912
|
}
|
|
680
913
|
} catch (e) {
|
|
681
|
-
|
|
914
|
+
// Only log if it's not an execution context error
|
|
915
|
+
if (!e.message.includes('Execution context was destroyed')) {
|
|
916
|
+
console.warn('DOM timeout cleanup warning:', e.message)
|
|
917
|
+
this.hasCleanupError = true
|
|
918
|
+
this.testFailures.push(`DOM cleanup failed: ${e.message}`)
|
|
919
|
+
}
|
|
682
920
|
}
|
|
683
921
|
|
|
684
|
-
//
|
|
685
|
-
|
|
922
|
+
// If we have cleanup errors, throw to fail the test suite
|
|
923
|
+
if (this.hasCleanupError && this.testFailures.length > 0) {
|
|
924
|
+
const errorMessage = `Test suite cleanup failed: ${this.testFailures.join('; ')}`
|
|
925
|
+
console.error(errorMessage)
|
|
926
|
+
throw new Error(errorMessage)
|
|
927
|
+
}
|
|
686
928
|
}
|
|
687
929
|
|
|
688
|
-
_afterSuite() {}
|
|
689
|
-
|
|
690
930
|
async _finishTest() {
|
|
691
|
-
if ((restartsSession() || restartsContext()) && this.isRunning)
|
|
931
|
+
if ((restartsSession() || restartsContext() || restartsBrowser()) && this.isRunning) {
|
|
932
|
+
try {
|
|
933
|
+
await Promise.race([this._stopBrowser(), new Promise((_, reject) => setTimeout(() => reject(new Error('Test finish timeout')), 10000))])
|
|
934
|
+
} catch (e) {
|
|
935
|
+
console.warn('Warning during test finish cleanup:', e.message)
|
|
936
|
+
// Track cleanup failures to prevent false positives
|
|
937
|
+
this.hasCleanupError = true
|
|
938
|
+
this.testFailures.push(`Test finish cleanup failed: ${e.message}`)
|
|
939
|
+
|
|
940
|
+
this.isRunning = false
|
|
941
|
+
// Set flags to prevent further operations after cleanup failure
|
|
942
|
+
this.page = null
|
|
943
|
+
this.browserContext = null
|
|
944
|
+
this.browser = null
|
|
945
|
+
|
|
946
|
+
// Propagate the error to fail the test properly
|
|
947
|
+
throw new Error(`Test cleanup failed: ${e.message}`)
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
async _cleanup() {
|
|
953
|
+
// Final cleanup when test run completes
|
|
954
|
+
if (this.isRunning) {
|
|
955
|
+
try {
|
|
956
|
+
// Add timeout protection to prevent hanging
|
|
957
|
+
await Promise.race([
|
|
958
|
+
this._stopBrowser(),
|
|
959
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('Browser stop timeout in cleanup')), 10000)),
|
|
960
|
+
])
|
|
961
|
+
} catch (e) {
|
|
962
|
+
console.warn('Warning during final cleanup:', e.message)
|
|
963
|
+
// Force cleanup on timeout
|
|
964
|
+
this.browser = null
|
|
965
|
+
this.browserContext = null
|
|
966
|
+
this.isRunning = false
|
|
967
|
+
}
|
|
968
|
+
} else {
|
|
969
|
+
// Check if we still have a browser object despite isRunning being false
|
|
970
|
+
if (this.browser) {
|
|
971
|
+
try {
|
|
972
|
+
// Add timeout protection to prevent hanging
|
|
973
|
+
await Promise.race([
|
|
974
|
+
this._stopBrowser(),
|
|
975
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('Browser stop timeout in forced cleanup')), 10000)),
|
|
976
|
+
])
|
|
977
|
+
} catch (e) {
|
|
978
|
+
console.warn('Warning during forced cleanup:', e.message)
|
|
979
|
+
// Force cleanup on timeout
|
|
980
|
+
this.browser = null
|
|
981
|
+
this.browserContext = null
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
}
|
|
692
985
|
}
|
|
693
986
|
|
|
694
987
|
_session() {
|
|
@@ -707,13 +1000,20 @@ class Playwright extends Helper {
|
|
|
707
1000
|
page = await browser.firstWindow()
|
|
708
1001
|
} else {
|
|
709
1002
|
try {
|
|
710
|
-
|
|
711
|
-
|
|
1003
|
+
// Check if browser is still available before creating context
|
|
1004
|
+
if (!this.browser) {
|
|
1005
|
+
throw new Error('Browser is not available for session context creation')
|
|
1006
|
+
}
|
|
1007
|
+
browserContext = await Promise.race([this.browser.newContext(Object.assign(this.contextOptions, config)), new Promise((_, reject) => setTimeout(() => reject(new Error('New context timeout')), 10000))])
|
|
1008
|
+
page = await Promise.race([browserContext.newPage(), new Promise((_, reject) => setTimeout(() => reject(new Error('New page timeout')), 5000))])
|
|
712
1009
|
} catch (e) {
|
|
1010
|
+
console.warn('Warning during context creation:', e.message)
|
|
713
1011
|
if (this.playwrightOptions.userDataDir) {
|
|
714
1012
|
browserContext = await playwright[this.options.browser].launchPersistentContext(`${this.userDataDir}_${this.activeSessionName}`, this.playwrightOptions)
|
|
715
1013
|
this.browser = browserContext
|
|
716
1014
|
page = await browserContext.pages()[0]
|
|
1015
|
+
} else {
|
|
1016
|
+
throw e
|
|
717
1017
|
}
|
|
718
1018
|
}
|
|
719
1019
|
}
|
|
@@ -744,8 +1044,28 @@ class Playwright extends Helper {
|
|
|
744
1044
|
} else {
|
|
745
1045
|
this.activeSessionName = session
|
|
746
1046
|
}
|
|
747
|
-
|
|
748
|
-
|
|
1047
|
+
|
|
1048
|
+
// Safety check: ensure browserContext exists before calling pages()
|
|
1049
|
+
if (!this.browserContext) {
|
|
1050
|
+
this.debug('Cannot restore session vars: browserContext is undefined')
|
|
1051
|
+
return
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
try {
|
|
1055
|
+
const existingPages = await this.browserContext.pages()
|
|
1056
|
+
if (existingPages && existingPages.length > 0) {
|
|
1057
|
+
await this._setPage(existingPages[0])
|
|
1058
|
+
// Reset context-related variables to ensure clean state after session
|
|
1059
|
+
this.context = await this.page
|
|
1060
|
+
this.contextLocator = null
|
|
1061
|
+
this.frame = null
|
|
1062
|
+
} else {
|
|
1063
|
+
this.debug('Cannot restore session vars: no pages available')
|
|
1064
|
+
}
|
|
1065
|
+
} catch (err) {
|
|
1066
|
+
this.debug(`Failed to restore session vars: ${err.message}`)
|
|
1067
|
+
return
|
|
1068
|
+
}
|
|
749
1069
|
|
|
750
1070
|
return this._waitForAction()
|
|
751
1071
|
},
|
|
@@ -831,21 +1151,46 @@ class Playwright extends Helper {
|
|
|
831
1151
|
* @param {object} page page to set
|
|
832
1152
|
*/
|
|
833
1153
|
async _setPage(page) {
|
|
1154
|
+
// Clean up previous page event listeners
|
|
1155
|
+
if (this.page && this.page !== page) {
|
|
1156
|
+
try {
|
|
1157
|
+
this.page.removeAllListeners('crash')
|
|
1158
|
+
this.page.removeAllListeners('dialog')
|
|
1159
|
+
this.page.removeAllListeners('load')
|
|
1160
|
+
this.page.removeAllListeners('console')
|
|
1161
|
+
this.page.removeAllListeners('requestfinished')
|
|
1162
|
+
} catch (e) {
|
|
1163
|
+
console.warn('Warning cleaning previous page listeners:', e.message)
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
|
|
834
1167
|
page = await page
|
|
835
1168
|
this._addPopupListener(page)
|
|
836
1169
|
this.page = page
|
|
837
1170
|
if (!page) return
|
|
838
|
-
this.browserContext.setDefaultTimeout(0)
|
|
839
|
-
page.setDefaultNavigationTimeout(this.options.getPageTimeout)
|
|
840
|
-
page.setDefaultTimeout(this.options.timeout)
|
|
841
1171
|
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
1172
|
+
try {
|
|
1173
|
+
this.browserContext.setDefaultTimeout(0)
|
|
1174
|
+
page.setDefaultNavigationTimeout(this.options.getPageTimeout)
|
|
1175
|
+
page.setDefaultTimeout(this.options.timeout)
|
|
1176
|
+
|
|
1177
|
+
page.on('crash', async () => {
|
|
1178
|
+
console.log('ERROR: Page has crashed, closing page!')
|
|
1179
|
+
try {
|
|
1180
|
+
await page.close()
|
|
1181
|
+
} catch (e) {
|
|
1182
|
+
console.warn('Warning during crashed page cleanup:', e.message)
|
|
1183
|
+
}
|
|
1184
|
+
})
|
|
1185
|
+
|
|
1186
|
+
this.context = await this.page
|
|
1187
|
+
this.contextLocator = null
|
|
1188
|
+
await page.bringToFront()
|
|
1189
|
+
} catch (e) {
|
|
1190
|
+
console.warn('Warning during page setup:', e.message)
|
|
1191
|
+
this.context = await this.page
|
|
1192
|
+
this.contextLocator = null
|
|
1193
|
+
}
|
|
849
1194
|
}
|
|
850
1195
|
|
|
851
1196
|
/**
|
|
@@ -903,7 +1248,10 @@ class Playwright extends Helper {
|
|
|
903
1248
|
|
|
904
1249
|
async _startBrowser() {
|
|
905
1250
|
// Ensure custom locator strategies are registered before browser launch
|
|
906
|
-
|
|
1251
|
+
// Only init once globally to avoid selector re-registration in workers
|
|
1252
|
+
if (!defaultSelectorEnginesInitialized) {
|
|
1253
|
+
await this._init()
|
|
1254
|
+
}
|
|
907
1255
|
|
|
908
1256
|
if (this.isElectron) {
|
|
909
1257
|
this.browser = await playwright._electron.launch(this.playwrightOptions)
|
|
@@ -970,6 +1318,9 @@ class Playwright extends Helper {
|
|
|
970
1318
|
* @param {object} [contextOptions] See https://playwright.dev/docs/api/class-browser#browser-new-context
|
|
971
1319
|
*/
|
|
972
1320
|
async _createContextPage(contextOptions) {
|
|
1321
|
+
if (!this.browser) {
|
|
1322
|
+
throw new Error('Browser not started. Call _startBrowser() first or disable manualStart option.')
|
|
1323
|
+
}
|
|
973
1324
|
this.browserContext = await this.browser.newContext(contextOptions)
|
|
974
1325
|
|
|
975
1326
|
// Register custom locator strategies for this context
|
|
@@ -1039,9 +1390,42 @@ class Playwright extends Helper {
|
|
|
1039
1390
|
this.context = null
|
|
1040
1391
|
this.frame = null
|
|
1041
1392
|
popupStore.clear()
|
|
1042
|
-
|
|
1393
|
+
|
|
1394
|
+
// Remove all event listeners to prevent hanging
|
|
1395
|
+
if (this.browser) {
|
|
1396
|
+
try {
|
|
1397
|
+
this.browser.removeAllListeners()
|
|
1398
|
+
} catch (e) {
|
|
1399
|
+
// Ignore errors if browser is already closed
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
if (this.options.recordHar && this.browserContext) {
|
|
1404
|
+
try {
|
|
1405
|
+
await this.browserContext.close()
|
|
1406
|
+
} catch (e) {
|
|
1407
|
+
// Ignore errors if context is already closed
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1043
1410
|
this.browserContext = null
|
|
1044
|
-
|
|
1411
|
+
|
|
1412
|
+
if (this.browser) {
|
|
1413
|
+
try {
|
|
1414
|
+
// Add timeout to prevent browser.close() from hanging indefinitely
|
|
1415
|
+
await Promise.race([
|
|
1416
|
+
this.browser.close(),
|
|
1417
|
+
new Promise((_, reject) =>
|
|
1418
|
+
setTimeout(() => reject(new Error('Browser close timeout')), 5000)
|
|
1419
|
+
)
|
|
1420
|
+
])
|
|
1421
|
+
} catch (e) {
|
|
1422
|
+
// Ignore errors if browser is already closed or timeout
|
|
1423
|
+
if (!e.message?.includes('Browser close timeout')) {
|
|
1424
|
+
// Non-timeout error, can be ignored as well
|
|
1425
|
+
}
|
|
1426
|
+
// Force cleanup even on error
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1045
1429
|
this.browser = null
|
|
1046
1430
|
this.isRunning = false
|
|
1047
1431
|
}
|
|
@@ -1060,8 +1444,21 @@ class Playwright extends Helper {
|
|
|
1060
1444
|
|
|
1061
1445
|
if (frame) {
|
|
1062
1446
|
if (Array.isArray(frame)) {
|
|
1447
|
+
// For nested frames, build the complete frame path
|
|
1063
1448
|
await this.switchTo(null)
|
|
1064
|
-
|
|
1449
|
+
|
|
1450
|
+
// Build nested frame locator from page
|
|
1451
|
+
let frameLocatorObj = this.page
|
|
1452
|
+
for (const frameSelector of frame) {
|
|
1453
|
+
const selector = buildLocatorString(new Locator(frameSelector, 'css'))
|
|
1454
|
+
frameLocatorObj = frameLocatorObj.frameLocator(selector)
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
this.frame = frameLocatorObj
|
|
1458
|
+
this.context = frameLocatorObj
|
|
1459
|
+
this.contextLocator = null
|
|
1460
|
+
this.withinLocator = new Locator(frame)
|
|
1461
|
+
return
|
|
1065
1462
|
}
|
|
1066
1463
|
await this.switchTo(frame)
|
|
1067
1464
|
this.withinLocator = new Locator(frame)
|
|
@@ -1078,7 +1475,11 @@ class Playwright extends Helper {
|
|
|
1078
1475
|
|
|
1079
1476
|
async _withinEnd() {
|
|
1080
1477
|
this.withinLocator = null
|
|
1081
|
-
|
|
1478
|
+
if (this.page) {
|
|
1479
|
+
this.context = await this.page
|
|
1480
|
+
} else {
|
|
1481
|
+
this.context = null
|
|
1482
|
+
}
|
|
1082
1483
|
this.contextLocator = null
|
|
1083
1484
|
this.frame = null
|
|
1084
1485
|
}
|
|
@@ -1101,6 +1502,13 @@ class Playwright extends Helper {
|
|
|
1101
1502
|
if (this.isElectron) {
|
|
1102
1503
|
throw new Error('Cannot open pages inside an Electron container')
|
|
1103
1504
|
}
|
|
1505
|
+
|
|
1506
|
+
// Prevent navigation attempts only when manual start is enabled and browser is not running
|
|
1507
|
+
// Allow auto-initialization for normal operation (e.g., when using BROWSER_RESTART=browser)
|
|
1508
|
+
if (!this.isRunning && this.options.manualStart && (!this.browser || !this.browserContext || !this.page)) {
|
|
1509
|
+
throw new Error('Cannot navigate: browser is not running or has been closed')
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1104
1512
|
if (!/^\w+\:(\/\/|.+)/.test(url)) {
|
|
1105
1513
|
url = this.options.url + (!this.options.url.endsWith('/') && url.startsWith('/') ? url : `/${url}`)
|
|
1106
1514
|
this.debug(`Changed URL to base url + relative path: ${url}`)
|
|
@@ -1113,7 +1521,91 @@ class Playwright extends Helper {
|
|
|
1113
1521
|
}
|
|
1114
1522
|
}
|
|
1115
1523
|
|
|
1116
|
-
|
|
1524
|
+
// Ensure browser is initialized before page operations
|
|
1525
|
+
if (!this.page) {
|
|
1526
|
+
this.debugSection('Auto-initializing', `Browser not started properly. page=${!!this.page}, isRunning=${this.isRunning}, browser=${!!this.browser}, browserContext=${!!this.browserContext}`)
|
|
1527
|
+
|
|
1528
|
+
if (!this.browser) {
|
|
1529
|
+
await this._startBrowser()
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
// Create browser context and page (simplified version of _before logic)
|
|
1533
|
+
if (!this.browserContext) {
|
|
1534
|
+
if (!this.browser) {
|
|
1535
|
+
throw new Error('Browser is not available for context creation. Browser may have been closed.')
|
|
1536
|
+
}
|
|
1537
|
+
const contextOptions = {
|
|
1538
|
+
ignoreHTTPSErrors: this.options.ignoreHTTPSErrors,
|
|
1539
|
+
acceptDownloads: true,
|
|
1540
|
+
...this.options.emulate,
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
try {
|
|
1544
|
+
this.browserContext = await this.browser.newContext(contextOptions)
|
|
1545
|
+
} catch (err) {
|
|
1546
|
+
// In worker mode with Playwright 1.x, there's a known issue where newContext() fails
|
|
1547
|
+
// with "selector engine already registered" when selectors are registered globally
|
|
1548
|
+
// across worker threads. This is safe to retry without ANY custom options.
|
|
1549
|
+
if (err.message && err.message.includes('already registered')) {
|
|
1550
|
+
this.debugSection('Worker Mode', 'Selector conflict in amOnPage, retrying with empty options')
|
|
1551
|
+
// Create context with NO options to avoid selector conflicts
|
|
1552
|
+
this.browserContext = await this.browser.newContext()
|
|
1553
|
+
} else {
|
|
1554
|
+
throw err
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
let pages
|
|
1560
|
+
let mainPage
|
|
1561
|
+
try {
|
|
1562
|
+
pages = await this.browserContext.pages()
|
|
1563
|
+
mainPage = pages[0] || (await this.browserContext.newPage())
|
|
1564
|
+
} catch (e) {
|
|
1565
|
+
if (e.message.includes('Target page, context or browser has been closed') || e.message.includes('Browser has been closed')) {
|
|
1566
|
+
throw new Error('Cannot create page: browser context has been closed')
|
|
1567
|
+
}
|
|
1568
|
+
throw e
|
|
1569
|
+
}
|
|
1570
|
+
await this._setPage(mainPage)
|
|
1571
|
+
|
|
1572
|
+
this.debugSection('Auto-initializing', `Completed. page=${!!this.page}, browserContext=${!!this.browserContext}`)
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
// Additional safety check
|
|
1576
|
+
if (!this.page) {
|
|
1577
|
+
throw new Error(`Page is not initialized after auto-initialization. this.page=${this.page}, this.isRunning=${this.isRunning}, this.browser=${!!this.browser}, this.browserContext=${!!this.browserContext}`)
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
try {
|
|
1581
|
+
// Additional validation before navigation
|
|
1582
|
+
if (this.page && this.page.isClosed && this.page.isClosed()) {
|
|
1583
|
+
throw new Error('Cannot navigate: page has been closed')
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
if (this.browserContext) {
|
|
1587
|
+
// Try to check if context is still valid
|
|
1588
|
+
try {
|
|
1589
|
+
await Promise.race([this.browserContext.pages(), new Promise((_, reject) => setTimeout(() => reject(new Error('Context check timeout')), 1000))])
|
|
1590
|
+
} catch (contextError) {
|
|
1591
|
+
throw new Error('Cannot navigate: browser context is invalid or closed')
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
await this.page.goto(url, { waitUntil: this.options.waitForNavigation })
|
|
1596
|
+
} catch (err) {
|
|
1597
|
+
// Handle terminal navigation errors that shouldn't be retried
|
|
1598
|
+
if (
|
|
1599
|
+
err.message &&
|
|
1600
|
+
(err.message.includes('ERR_ABORTED') || err.message.includes('frame was detached') || err.message.includes('Target page, context or browser has been closed') || err.message.includes('Cannot navigate'))
|
|
1601
|
+
) {
|
|
1602
|
+
// Mark this as a terminal error to prevent retries
|
|
1603
|
+
const terminalError = new Error(err.message)
|
|
1604
|
+
terminalError.isTerminal = true
|
|
1605
|
+
throw terminalError
|
|
1606
|
+
}
|
|
1607
|
+
throw err
|
|
1608
|
+
}
|
|
1117
1609
|
|
|
1118
1610
|
const performanceTiming = JSON.parse(await this.page.evaluate(() => JSON.stringify(window.performance.timing)))
|
|
1119
1611
|
|
|
@@ -1251,44 +1743,25 @@ class Playwright extends Helper {
|
|
|
1251
1743
|
async dragAndDrop(srcElement, destElement, options) {
|
|
1252
1744
|
const src = new Locator(srcElement)
|
|
1253
1745
|
const dst = new Locator(destElement)
|
|
1746
|
+
const context = await this._getContext()
|
|
1254
1747
|
|
|
1255
1748
|
if (options) {
|
|
1256
|
-
return
|
|
1749
|
+
return context.dragAndDrop(buildLocatorString(src), buildLocatorString(dst), options)
|
|
1257
1750
|
}
|
|
1258
1751
|
|
|
1259
1752
|
const _smallWaitInMs = 600
|
|
1260
|
-
await
|
|
1753
|
+
await context.locator(buildLocatorString(src)).hover()
|
|
1261
1754
|
await this.page.mouse.down()
|
|
1262
1755
|
await this.page.waitForTimeout(_smallWaitInMs)
|
|
1263
1756
|
|
|
1264
|
-
const destElBox = await
|
|
1757
|
+
const destElBox = await context.locator(buildLocatorString(dst)).boundingBox()
|
|
1265
1758
|
|
|
1266
1759
|
await this.page.mouse.move(destElBox.x + destElBox.width / 2, destElBox.y + destElBox.height / 2)
|
|
1267
|
-
await
|
|
1760
|
+
await context.locator(buildLocatorString(dst)).hover({ position: { x: 10, y: 10 } })
|
|
1268
1761
|
await this.page.waitForTimeout(_smallWaitInMs)
|
|
1269
1762
|
await this.page.mouse.up()
|
|
1270
1763
|
}
|
|
1271
1764
|
|
|
1272
|
-
/**
|
|
1273
|
-
* Restart browser with a new context and a new page
|
|
1274
|
-
*
|
|
1275
|
-
* ```js
|
|
1276
|
-
* // Restart browser and use a new timezone
|
|
1277
|
-
* I.restartBrowser({ timezoneId: 'America/Phoenix' });
|
|
1278
|
-
* // Open URL in a new page in changed timezone
|
|
1279
|
-
* I.amOnPage('/');
|
|
1280
|
-
* // Restart browser, allow reading/copying of text from/into clipboard in Chrome
|
|
1281
|
-
* I.restartBrowser({ permissions: ['clipboard-read', 'clipboard-write'] });
|
|
1282
|
-
* ```
|
|
1283
|
-
*
|
|
1284
|
-
* @param {object} [contextOptions] [Options for browser context](https://playwright.dev/docs/api/class-browser#browser-new-context) when starting new browser
|
|
1285
|
-
*/
|
|
1286
|
-
async restartBrowser(contextOptions) {
|
|
1287
|
-
await this._stopBrowser()
|
|
1288
|
-
await this._startBrowser()
|
|
1289
|
-
await this._createContextPage(contextOptions)
|
|
1290
|
-
}
|
|
1291
|
-
|
|
1292
1765
|
/**
|
|
1293
1766
|
* {{> refreshPage }}
|
|
1294
1767
|
*/
|
|
@@ -1734,7 +2207,7 @@ class Playwright extends Helper {
|
|
|
1734
2207
|
* ```
|
|
1735
2208
|
*
|
|
1736
2209
|
*/
|
|
1737
|
-
async click(locator, context = null, options = {}) {
|
|
2210
|
+
async click(locator = '//body', context = null, options = {}) {
|
|
1738
2211
|
return proceedClick.call(this, locator, context, options)
|
|
1739
2212
|
}
|
|
1740
2213
|
|
|
@@ -1768,6 +2241,49 @@ class Playwright extends Helper {
|
|
|
1768
2241
|
return proceedClick.call(this, locator, context, { button: 'right' })
|
|
1769
2242
|
}
|
|
1770
2243
|
|
|
2244
|
+
/**
|
|
2245
|
+
* Performs click at specific coordinates.
|
|
2246
|
+
* If locator is provided, the coordinates are relative to the element.
|
|
2247
|
+
* If locator is not provided, the coordinates are global page coordinates.
|
|
2248
|
+
*
|
|
2249
|
+
* ```js
|
|
2250
|
+
* // Click at global coordinates (100, 200)
|
|
2251
|
+
* I.clickXY(100, 200);
|
|
2252
|
+
*
|
|
2253
|
+
* // Click at coordinates (50, 30) relative to element
|
|
2254
|
+
* I.clickXY('#someElement', 50, 30);
|
|
2255
|
+
* ```
|
|
2256
|
+
*
|
|
2257
|
+
* @param {CodeceptJS.LocatorOrString|number} locator Element to click on or X coordinate if no element.
|
|
2258
|
+
* @param {number} [x] X coordinate relative to element, or Y coordinate if locator is a number.
|
|
2259
|
+
* @param {number} [y] Y coordinate relative to element.
|
|
2260
|
+
* @returns {Promise<void>}
|
|
2261
|
+
*/
|
|
2262
|
+
async clickXY(locator, x, y) {
|
|
2263
|
+
// If locator is a number, treat it as global X coordinate
|
|
2264
|
+
if (typeof locator === 'number') {
|
|
2265
|
+
const globalX = locator
|
|
2266
|
+
const globalY = x
|
|
2267
|
+
await this.page.mouse.click(globalX, globalY)
|
|
2268
|
+
return this._waitForAction()
|
|
2269
|
+
}
|
|
2270
|
+
|
|
2271
|
+
// Locator is provided, click relative to element
|
|
2272
|
+
const el = await this._locateElement(locator)
|
|
2273
|
+
assertElementExists(el, locator, 'Element to click')
|
|
2274
|
+
|
|
2275
|
+
const box = await el.boundingBox()
|
|
2276
|
+
if (!box) {
|
|
2277
|
+
throw new Error(`Element ${locator} is not visible or has no bounding box`)
|
|
2278
|
+
}
|
|
2279
|
+
|
|
2280
|
+
const absoluteX = box.x + x
|
|
2281
|
+
const absoluteY = box.y + y
|
|
2282
|
+
|
|
2283
|
+
await this.page.mouse.click(absoluteX, absoluteY)
|
|
2284
|
+
return this._waitForAction()
|
|
2285
|
+
}
|
|
2286
|
+
|
|
1771
2287
|
/**
|
|
1772
2288
|
*
|
|
1773
2289
|
* [Additional options](https://playwright.dev/docs/api/class-elementhandle#element-handle-check) for check available as 3rd argument.
|
|
@@ -1876,11 +2392,15 @@ class Playwright extends Helper {
|
|
|
1876
2392
|
* {{> type }}
|
|
1877
2393
|
*/
|
|
1878
2394
|
async type(keys, delay = null) {
|
|
2395
|
+
// Always use page.keyboard.type for any string (including single character and national characters).
|
|
1879
2396
|
if (!Array.isArray(keys)) {
|
|
1880
2397
|
keys = keys.toString()
|
|
1881
|
-
|
|
2398
|
+
const typeDelay = typeof delay === 'number' ? delay : this.options.pressKeyDelay
|
|
2399
|
+
await this.page.keyboard.type(keys, { delay: typeDelay })
|
|
2400
|
+
return
|
|
1882
2401
|
}
|
|
1883
2402
|
|
|
2403
|
+
// For array input, treat each as a key press to keep working combinations such as ['Control', 'A'] or ['T', 'e', 's', 't'].
|
|
1884
2404
|
for (const key of keys) {
|
|
1885
2405
|
await this.page.keyboard.press(key)
|
|
1886
2406
|
if (delay) await this.wait(delay / 1000)
|
|
@@ -2166,10 +2686,21 @@ class Playwright extends Helper {
|
|
|
2166
2686
|
* {{> grabCookie }}
|
|
2167
2687
|
*/
|
|
2168
2688
|
async grabCookie(name) {
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2689
|
+
if (!this.browserContext) {
|
|
2690
|
+
throw new Error('Browser context is not available for grabCookie')
|
|
2691
|
+
}
|
|
2692
|
+
|
|
2693
|
+
try {
|
|
2694
|
+
const cookies = await this.browserContext.cookies()
|
|
2695
|
+
if (!name) return cookies
|
|
2696
|
+
const cookie = cookies.filter(c => c.name === name)
|
|
2697
|
+
if (cookie[0]) return cookie[0]
|
|
2698
|
+
} catch (err) {
|
|
2699
|
+
if (err.message.includes('Target page, context or browser has been closed') || err.message.includes('Browser has been closed')) {
|
|
2700
|
+
throw new Error('Cannot grab cookies: browser context has been closed')
|
|
2701
|
+
}
|
|
2702
|
+
throw err
|
|
2703
|
+
}
|
|
2173
2704
|
}
|
|
2174
2705
|
|
|
2175
2706
|
/**
|
|
@@ -2274,6 +2805,17 @@ class Playwright extends Helper {
|
|
|
2274
2805
|
*
|
|
2275
2806
|
*/
|
|
2276
2807
|
async grabTextFrom(locator) {
|
|
2808
|
+
// Handle role locators with text/exact options
|
|
2809
|
+
if (isRoleLocatorObject(locator)) {
|
|
2810
|
+
const elements = await handleRoleLocator(this.page, locator)
|
|
2811
|
+
if (elements && elements.length > 0) {
|
|
2812
|
+
const text = await elements[0].textContent()
|
|
2813
|
+
assertElementExists(text, JSON.stringify(locator))
|
|
2814
|
+
this.debugSection('Text', text)
|
|
2815
|
+
return text
|
|
2816
|
+
}
|
|
2817
|
+
}
|
|
2818
|
+
|
|
2277
2819
|
const locatorObj = new Locator(locator, 'css')
|
|
2278
2820
|
|
|
2279
2821
|
if (locatorObj.isCustom()) {
|
|
@@ -2288,10 +2830,18 @@ class Playwright extends Helper {
|
|
|
2288
2830
|
return text
|
|
2289
2831
|
} else {
|
|
2290
2832
|
locator = this._contextLocator(locator)
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2833
|
+
try {
|
|
2834
|
+
const text = await this.page.textContent(locator)
|
|
2835
|
+
assertElementExists(text, locator)
|
|
2836
|
+
this.debugSection('Text', text)
|
|
2837
|
+
return text
|
|
2838
|
+
} catch (error) {
|
|
2839
|
+
// Convert Playwright timeout errors to ElementNotFound for consistency
|
|
2840
|
+
if (error.message && error.message.includes('Timeout')) {
|
|
2841
|
+
throw new ElementNotFound(locator, 'text')
|
|
2842
|
+
}
|
|
2843
|
+
throw error
|
|
2844
|
+
}
|
|
2295
2845
|
}
|
|
2296
2846
|
}
|
|
2297
2847
|
|
|
@@ -2480,6 +3030,33 @@ class Playwright extends Helper {
|
|
|
2480
3030
|
return array
|
|
2481
3031
|
}
|
|
2482
3032
|
|
|
3033
|
+
/**
|
|
3034
|
+
* Retrieves the ARIA snapshot for an element using Playwright's [`locator.ariaSnapshot`](https://playwright.dev/docs/api/class-locator#locator-aria-snapshot).
|
|
3035
|
+
* This method returns a YAML representation of the accessibility tree that can be used for assertions.
|
|
3036
|
+
* If no locator is provided, it captures the snapshot of the entire page body.
|
|
3037
|
+
*
|
|
3038
|
+
* ```js
|
|
3039
|
+
* const snapshot = await I.grabAriaSnapshot();
|
|
3040
|
+
* expect(snapshot).toContain('heading "Sign up"');
|
|
3041
|
+
*
|
|
3042
|
+
* const formSnapshot = await I.grabAriaSnapshot('#login-form');
|
|
3043
|
+
* expect(formSnapshot).toContain('textbox "Email"');
|
|
3044
|
+
* ```
|
|
3045
|
+
*
|
|
3046
|
+
* [Learn more about ARIA snapshots](https://playwright.dev/docs/aria-snapshots)
|
|
3047
|
+
*
|
|
3048
|
+
* @param {string|object} [locator='//body'] element located by CSS|XPath|strict locator. Defaults to body element.
|
|
3049
|
+
* @return {Promise<string>} YAML representation of the accessibility tree
|
|
3050
|
+
*/
|
|
3051
|
+
async grabAriaSnapshot(locator = '//body') {
|
|
3052
|
+
const matchedLocator = new Locator(locator)
|
|
3053
|
+
const els = await this._locate(matchedLocator)
|
|
3054
|
+
assertElementExists(els, locator)
|
|
3055
|
+
const snapshot = await els[0].ariaSnapshot()
|
|
3056
|
+
this.debugSection('Aria Snapshot', snapshot)
|
|
3057
|
+
return snapshot
|
|
3058
|
+
}
|
|
3059
|
+
|
|
2483
3060
|
/**
|
|
2484
3061
|
* {{> saveElementScreenshot }}
|
|
2485
3062
|
*
|
|
@@ -2502,25 +3079,70 @@ class Playwright extends Helper {
|
|
|
2502
3079
|
|
|
2503
3080
|
this.debugSection('Screenshot', relativeDir(outputFile))
|
|
2504
3081
|
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
3082
|
+
if (!this.page || !this.browser || !this.browserContext) {
|
|
3083
|
+
this.debug(`Cannot take screenshot: page=${!!this.page}, browser=${!!this.browser}, browserContext=${!!this.browserContext}`)
|
|
3084
|
+
return
|
|
3085
|
+
}
|
|
3086
|
+
if (this.page.isClosed && this.page.isClosed()) {
|
|
3087
|
+
this.debug('Cannot take screenshot: page is closed')
|
|
3088
|
+
return
|
|
3089
|
+
}
|
|
3090
|
+
|
|
3091
|
+
try {
|
|
3092
|
+
await Promise.race([
|
|
3093
|
+
this.page.screenshot({
|
|
3094
|
+
path: outputFile,
|
|
3095
|
+
fullPage: fullPageOption,
|
|
3096
|
+
type: 'png',
|
|
3097
|
+
}),
|
|
3098
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('Screenshot timeout')), 5000)),
|
|
3099
|
+
])
|
|
3100
|
+
} catch (err) {
|
|
3101
|
+
this.debug(`Failed to take screenshot: ${err.message}`)
|
|
3102
|
+
|
|
3103
|
+
this.hasCleanupError = true
|
|
3104
|
+
this.testFailures.push(`Screenshot failed: ${err.message}`)
|
|
3105
|
+
|
|
3106
|
+
if (err.message.includes('closed') || err.message.includes('Protocol error') || err.message.includes('timeout')) {
|
|
3107
|
+
this.debug('Screenshot failed due to browser/page closure or timeout, continuing...')
|
|
3108
|
+
return
|
|
3109
|
+
}
|
|
3110
|
+
throw err
|
|
3111
|
+
}
|
|
2510
3112
|
|
|
2511
|
-
|
|
3113
|
+
// Handle session screenshots for ALL sessions, not just active one
|
|
3114
|
+
if (this.sessionPages && Object.keys(this.sessionPages).length > 0) {
|
|
2512
3115
|
for (const sessionName in this.sessionPages) {
|
|
2513
|
-
const
|
|
3116
|
+
const sessionPage = this.sessionPages[sessionName]
|
|
2514
3117
|
outputFile = screenshotOutputFolder(`${sessionName}_${fileName}`)
|
|
2515
3118
|
|
|
2516
3119
|
this.debugSection('Screenshot', `${sessionName} - ${relativeDir(outputFile)}`)
|
|
2517
3120
|
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
3121
|
+
try {
|
|
3122
|
+
// Add timeout protection for session screenshots
|
|
3123
|
+
await Promise.race([
|
|
3124
|
+
(async () => {
|
|
3125
|
+
if (sessionPage && !sessionPage.isClosed()) {
|
|
3126
|
+
await sessionPage.screenshot({
|
|
3127
|
+
path: outputFile,
|
|
3128
|
+
fullPage: fullPageOption,
|
|
3129
|
+
type: 'png',
|
|
3130
|
+
})
|
|
3131
|
+
} else {
|
|
3132
|
+
this.debug(`Cannot take session screenshot: session page for '${sessionName}' is closed or undefined`)
|
|
3133
|
+
}
|
|
3134
|
+
})(),
|
|
3135
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('Session screenshot timeout')), 3000)),
|
|
3136
|
+
])
|
|
3137
|
+
} catch (err) {
|
|
3138
|
+
this.debug(`Failed to take session screenshot for '${sessionName}': ${err.message}`)
|
|
3139
|
+
|
|
3140
|
+
// Track session screenshot failures
|
|
3141
|
+
this.hasCleanupError = true
|
|
3142
|
+
this.testFailures.push(`Session screenshot failed for '${sessionName}': ${err.message}`)
|
|
3143
|
+
|
|
3144
|
+
// Don't throw here - main screenshot was successful and we don't want to hang
|
|
3145
|
+
// Just log and continue
|
|
2524
3146
|
}
|
|
2525
3147
|
}
|
|
2526
3148
|
}
|
|
@@ -3130,8 +3752,13 @@ class Playwright extends Helper {
|
|
|
3130
3752
|
}
|
|
3131
3753
|
|
|
3132
3754
|
if (locator >= 0 && locator < childFrames.length) {
|
|
3133
|
-
|
|
3134
|
-
|
|
3755
|
+
try {
|
|
3756
|
+
this.context = await Promise.race([this.page.frameLocator('iframe').nth(locator), new Promise((_, reject) => setTimeout(() => reject(new Error('Frame locator timeout')), 5000))])
|
|
3757
|
+
this.contextLocator = locator
|
|
3758
|
+
} catch (e) {
|
|
3759
|
+
console.warn('Warning during frame selection:', e.message)
|
|
3760
|
+
throw new Error('Element #invalidIframeSelector was not found by text|CSS|XPath')
|
|
3761
|
+
}
|
|
3135
3762
|
} else {
|
|
3136
3763
|
throw new Error('Element #invalidIframeSelector was not found by text|CSS|XPath')
|
|
3137
3764
|
}
|
|
@@ -3147,16 +3774,25 @@ class Playwright extends Helper {
|
|
|
3147
3774
|
|
|
3148
3775
|
// iframe by selector
|
|
3149
3776
|
locator = buildLocatorString(new Locator(locator, 'css'))
|
|
3150
|
-
|
|
3777
|
+
|
|
3778
|
+
let frame
|
|
3779
|
+
try {
|
|
3780
|
+
frame = await Promise.race([this._locateElement(locator), new Promise((_, reject) => setTimeout(() => reject(new Error('Locate frame timeout')), 5000))])
|
|
3781
|
+
} catch (e) {
|
|
3782
|
+
console.warn('Warning during frame location:', e.message)
|
|
3783
|
+
frame = null
|
|
3784
|
+
}
|
|
3151
3785
|
|
|
3152
3786
|
if (!frame) {
|
|
3153
3787
|
throw new Error(`Frame ${JSON.stringify(locator)} was not found by text|CSS|XPath`)
|
|
3154
3788
|
}
|
|
3155
3789
|
|
|
3156
|
-
|
|
3157
|
-
|
|
3158
|
-
|
|
3159
|
-
|
|
3790
|
+
try {
|
|
3791
|
+
// Always create frame locator from page to avoid nested frame paths
|
|
3792
|
+
this.frame = await Promise.race([this.page.frameLocator(locator), new Promise((_, reject) => setTimeout(() => reject(new Error('Frame locator timeout')), 5000))])
|
|
3793
|
+
} catch (e) {
|
|
3794
|
+
console.warn('Warning during frame locator creation:', e.message)
|
|
3795
|
+
throw new Error(`Frame ${JSON.stringify(locator)} could not be accessed`)
|
|
3160
3796
|
}
|
|
3161
3797
|
|
|
3162
3798
|
const contentFrame = this.frame
|
|
@@ -3165,8 +3801,14 @@ class Playwright extends Helper {
|
|
|
3165
3801
|
this.context = contentFrame
|
|
3166
3802
|
this.contextLocator = null
|
|
3167
3803
|
} else {
|
|
3168
|
-
|
|
3169
|
-
|
|
3804
|
+
try {
|
|
3805
|
+
this.context = this.page.frame(this.page.frames()[1].name())
|
|
3806
|
+
this.contextLocator = locator
|
|
3807
|
+
} catch (e) {
|
|
3808
|
+
console.warn('Warning during frame context setup:', e.message)
|
|
3809
|
+
this.context = this.page
|
|
3810
|
+
this.contextLocator = null
|
|
3811
|
+
}
|
|
3170
3812
|
}
|
|
3171
3813
|
}
|
|
3172
3814
|
|
|
@@ -3664,7 +4306,7 @@ class Playwright extends Helper {
|
|
|
3664
4306
|
}
|
|
3665
4307
|
}
|
|
3666
4308
|
|
|
3667
|
-
|
|
4309
|
+
export default Playwright
|
|
3668
4310
|
|
|
3669
4311
|
function buildCustomLocatorString(locator) {
|
|
3670
4312
|
// Note: this.debug not available in standalone function, using console.log
|
|
@@ -3682,10 +4324,40 @@ function buildLocatorString(locator) {
|
|
|
3682
4324
|
return locator.simplify()
|
|
3683
4325
|
}
|
|
3684
4326
|
|
|
4327
|
+
/**
|
|
4328
|
+
* Checks if a locator is a role locator object (e.g., {role: 'button', text: 'Submit', exact: true})
|
|
4329
|
+
*/
|
|
4330
|
+
function isRoleLocatorObject(locator) {
|
|
4331
|
+
return locator && typeof locator === 'object' && locator.role && !locator.type
|
|
4332
|
+
}
|
|
4333
|
+
|
|
4334
|
+
/**
|
|
4335
|
+
* Handles role locator objects by converting them to Playwright's getByRole() API
|
|
4336
|
+
* Returns elements array if role locator, null otherwise
|
|
4337
|
+
*/
|
|
4338
|
+
async function handleRoleLocator(context, locator) {
|
|
4339
|
+
if (!isRoleLocatorObject(locator)) return null
|
|
4340
|
+
|
|
4341
|
+
const options = {}
|
|
4342
|
+
if (locator.text) options.name = locator.text
|
|
4343
|
+
if (locator.exact !== undefined) options.exact = locator.exact
|
|
4344
|
+
|
|
4345
|
+
return context.getByRole(locator.role, Object.keys(options).length > 0 ? options : undefined).all()
|
|
4346
|
+
}
|
|
4347
|
+
|
|
3685
4348
|
async function findElements(matcher, locator) {
|
|
3686
|
-
if
|
|
3687
|
-
|
|
3688
|
-
|
|
4349
|
+
// Check if locator is a Locator object with react/vue type, or a raw object with react/vue property
|
|
4350
|
+
const isReactLocator = locator.type === 'react' || (locator.locator && locator.locator.react) || locator.react
|
|
4351
|
+
const isVueLocator = locator.type === 'vue' || (locator.locator && locator.locator.vue) || locator.vue
|
|
4352
|
+
const isPwLocator = locator.type === 'pw' || (locator.locator && locator.locator.pw) || locator.pw
|
|
4353
|
+
|
|
4354
|
+
if (isReactLocator) return findReact(matcher, locator)
|
|
4355
|
+
if (isVueLocator) return findVue(matcher, locator)
|
|
4356
|
+
if (isPwLocator) return findByPlaywrightLocator.call(this, matcher, locator)
|
|
4357
|
+
|
|
4358
|
+
// Handle role locators with text/exact options (e.g., {role: 'button', text: 'Submit', exact: true})
|
|
4359
|
+
const roleElements = await handleRoleLocator(matcher, locator)
|
|
4360
|
+
if (roleElements) return roleElements
|
|
3689
4361
|
|
|
3690
4362
|
locator = new Locator(locator, 'css')
|
|
3691
4363
|
|
|
@@ -3716,8 +4388,16 @@ async function findElements(matcher, locator) {
|
|
|
3716
4388
|
}
|
|
3717
4389
|
|
|
3718
4390
|
async function findCustomElements(matcher, locator) {
|
|
3719
|
-
|
|
3720
|
-
|
|
4391
|
+
// Always prioritize this.customLocatorStrategies which is set in constructor from config
|
|
4392
|
+
// and persists in every worker thread instance
|
|
4393
|
+
let strategyFunction = null
|
|
4394
|
+
|
|
4395
|
+
if (this.customLocatorStrategies && this.customLocatorStrategies[locator.type]) {
|
|
4396
|
+
strategyFunction = this.customLocatorStrategies[locator.type]
|
|
4397
|
+
} else if (globalCustomLocatorStrategies.has(locator.type)) {
|
|
4398
|
+
// Fallback to global registry (populated in constructor and _init)
|
|
4399
|
+
strategyFunction = globalCustomLocatorStrategies.get(locator.type)
|
|
4400
|
+
}
|
|
3721
4401
|
|
|
3722
4402
|
if (!strategyFunction) {
|
|
3723
4403
|
throw new Error(`Custom locator strategy "${locator.type}" is not defined. Please define "customLocatorStrategies" in your configuration.`)
|
|
@@ -3853,15 +4533,26 @@ async function proceedClick(locator, context = null, options = {}) {
|
|
|
3853
4533
|
}
|
|
3854
4534
|
|
|
3855
4535
|
async function findClickable(matcher, locator) {
|
|
3856
|
-
|
|
3857
|
-
if (locator.vue) return findVue(matcher, locator)
|
|
3858
|
-
if (locator.pw) return findByPlaywrightLocator.call(this, matcher, locator)
|
|
4536
|
+
const matchedLocator = new Locator(locator)
|
|
3859
4537
|
|
|
3860
|
-
|
|
3861
|
-
if (!locator.isFuzzy()) return findElements.call(this, matcher, locator)
|
|
4538
|
+
if (!matchedLocator.isFuzzy()) return findElements.call(this, matcher, matchedLocator)
|
|
3862
4539
|
|
|
3863
4540
|
let els
|
|
3864
|
-
const literal = xpathLocator.literal(
|
|
4541
|
+
const literal = xpathLocator.literal(matchedLocator.value)
|
|
4542
|
+
|
|
4543
|
+
try {
|
|
4544
|
+
els = await matcher.getByRole('button', { name: matchedLocator.value }).all()
|
|
4545
|
+
if (els.length) return els
|
|
4546
|
+
} catch (err) {
|
|
4547
|
+
// getByRole not supported or failed
|
|
4548
|
+
}
|
|
4549
|
+
|
|
4550
|
+
try {
|
|
4551
|
+
els = await matcher.getByRole('link', { name: matchedLocator.value }).all()
|
|
4552
|
+
if (els.length) return els
|
|
4553
|
+
} catch (err) {
|
|
4554
|
+
// getByRole not supported or failed
|
|
4555
|
+
}
|
|
3865
4556
|
|
|
3866
4557
|
els = await findElements.call(this, matcher, Locator.clickable.narrow(literal))
|
|
3867
4558
|
if (els.length) return els
|
|
@@ -3876,7 +4567,7 @@ async function findClickable(matcher, locator) {
|
|
|
3876
4567
|
// Do nothing
|
|
3877
4568
|
}
|
|
3878
4569
|
|
|
3879
|
-
return findElements.call(this, matcher,
|
|
4570
|
+
return findElements.call(this, matcher, matchedLocator.value) // by css or xpath
|
|
3880
4571
|
}
|
|
3881
4572
|
|
|
3882
4573
|
async function proceedSee(assertType, text, context, strict = false) {
|
|
@@ -3915,12 +4606,16 @@ async function findCheckable(locator, context) {
|
|
|
3915
4606
|
contextEl = contextEl[0]
|
|
3916
4607
|
}
|
|
3917
4608
|
|
|
4609
|
+
// Handle role locators with text/exact options
|
|
4610
|
+
const roleElements = await handleRoleLocator(contextEl, locator)
|
|
4611
|
+
if (roleElements) return roleElements
|
|
4612
|
+
|
|
3918
4613
|
const matchedLocator = new Locator(locator)
|
|
3919
4614
|
if (!matchedLocator.isFuzzy()) {
|
|
3920
|
-
return findElements.call(this, contextEl, matchedLocator
|
|
4615
|
+
return findElements.call(this, contextEl, matchedLocator)
|
|
3921
4616
|
}
|
|
3922
4617
|
|
|
3923
|
-
const literal = xpathLocator.literal(
|
|
4618
|
+
const literal = xpathLocator.literal(matchedLocator.value)
|
|
3924
4619
|
let els = await findElements.call(this, contextEl, Locator.checkable.byText(literal))
|
|
3925
4620
|
if (els.length) {
|
|
3926
4621
|
return els
|
|
@@ -3929,7 +4624,7 @@ async function findCheckable(locator, context) {
|
|
|
3929
4624
|
if (els.length) {
|
|
3930
4625
|
return els
|
|
3931
4626
|
}
|
|
3932
|
-
return findElements.call(this, contextEl,
|
|
4627
|
+
return findElements.call(this, contextEl, matchedLocator.value)
|
|
3933
4628
|
}
|
|
3934
4629
|
|
|
3935
4630
|
async function proceedIsChecked(assertType, option) {
|
|
@@ -3941,6 +4636,13 @@ async function proceedIsChecked(assertType, option) {
|
|
|
3941
4636
|
}
|
|
3942
4637
|
|
|
3943
4638
|
async function findFields(locator) {
|
|
4639
|
+
// Handle role locators with text/exact options
|
|
4640
|
+
if (isRoleLocatorObject(locator)) {
|
|
4641
|
+
const page = await this.page
|
|
4642
|
+
const roleElements = await handleRoleLocator(page, locator)
|
|
4643
|
+
if (roleElements) return roleElements
|
|
4644
|
+
}
|
|
4645
|
+
|
|
3944
4646
|
const matchedLocator = new Locator(locator)
|
|
3945
4647
|
if (!matchedLocator.isFuzzy()) {
|
|
3946
4648
|
return this._locate(matchedLocator)
|
|
@@ -4212,36 +4914,72 @@ async function clickablePoint(el) {
|
|
|
4212
4914
|
}
|
|
4213
4915
|
|
|
4214
4916
|
async function refreshContextSession() {
|
|
4215
|
-
// close other sessions
|
|
4917
|
+
// close other sessions with timeout protection, but preserve active session contexts
|
|
4216
4918
|
try {
|
|
4217
|
-
const contexts = await this.browser.contexts()
|
|
4218
|
-
contexts.shift()
|
|
4919
|
+
const contexts = await Promise.race([this.browser.contexts(), new Promise((_, reject) => setTimeout(() => reject(new Error('Get contexts timeout')), 3000))])
|
|
4219
4920
|
|
|
4220
|
-
|
|
4921
|
+
// Keep the first context (default) and any contexts that belong to active sessions
|
|
4922
|
+
const defaultContext = contexts.shift()
|
|
4923
|
+
const activeSessionContexts = new Set()
|
|
4924
|
+
|
|
4925
|
+
// Identify contexts that are still in use by active sessions
|
|
4926
|
+
if (this.sessionPages) {
|
|
4927
|
+
for (const sessionName in this.sessionPages) {
|
|
4928
|
+
const sessionPage = this.sessionPages[sessionName]
|
|
4929
|
+
if (sessionPage && sessionPage.context) {
|
|
4930
|
+
activeSessionContexts.add(sessionPage.context)
|
|
4931
|
+
}
|
|
4932
|
+
}
|
|
4933
|
+
}
|
|
4934
|
+
|
|
4935
|
+
// Only close contexts that are not in use by active sessions
|
|
4936
|
+
const contextsToClose = contexts.filter(context => !activeSessionContexts.has(context))
|
|
4937
|
+
|
|
4938
|
+
if (contextsToClose.length > 0) {
|
|
4939
|
+
await Promise.race([Promise.all(contextsToClose.map(c => c.close())), new Promise((_, reject) => setTimeout(() => reject(new Error('Close contexts timeout')), 5000))])
|
|
4940
|
+
}
|
|
4221
4941
|
} catch (e) {
|
|
4222
|
-
console.
|
|
4942
|
+
console.warn('Warning during context cleanup:', e.message)
|
|
4223
4943
|
}
|
|
4224
4944
|
|
|
4225
4945
|
if (this.page) {
|
|
4226
|
-
|
|
4227
|
-
|
|
4946
|
+
try {
|
|
4947
|
+
const existingPages = await this.browserContext.pages()
|
|
4948
|
+
await this._setPage(existingPages[0])
|
|
4949
|
+
} catch (e) {
|
|
4950
|
+
console.warn('Warning during page setup:', e.message)
|
|
4951
|
+
}
|
|
4228
4952
|
}
|
|
4229
4953
|
|
|
4230
4954
|
if (this.options.keepBrowserState) return
|
|
4231
4955
|
|
|
4232
4956
|
if (!this.options.keepCookies) {
|
|
4233
4957
|
this.debugSection('Session', 'cleaning cookies and localStorage')
|
|
4234
|
-
|
|
4958
|
+
try {
|
|
4959
|
+
await this.clearCookie()
|
|
4960
|
+
} catch (e) {
|
|
4961
|
+
console.warn('Warning during cookie cleanup:', e.message)
|
|
4962
|
+
}
|
|
4235
4963
|
}
|
|
4236
|
-
const currentUrl = await this.grabCurrentUrl()
|
|
4237
4964
|
|
|
4238
|
-
|
|
4239
|
-
|
|
4240
|
-
|
|
4241
|
-
|
|
4242
|
-
|
|
4243
|
-
|
|
4244
|
-
|
|
4965
|
+
try {
|
|
4966
|
+
if (!this.page || !this.browserContext) {
|
|
4967
|
+
this.debugSection('Session', 'Skipping storage cleanup - no active page/context')
|
|
4968
|
+
return
|
|
4969
|
+
}
|
|
4970
|
+
|
|
4971
|
+
const currentUrl = await this.grabCurrentUrl()
|
|
4972
|
+
|
|
4973
|
+
if (currentUrl.startsWith('http')) {
|
|
4974
|
+
await this.executeScript('localStorage.clear();').catch(err => {
|
|
4975
|
+
if (!(err.message.indexOf("Storage is disabled inside 'data:' URLs.") > -1)) throw err
|
|
4976
|
+
})
|
|
4977
|
+
await this.executeScript('sessionStorage.clear();').catch(err => {
|
|
4978
|
+
if (!(err.message.indexOf("Storage is disabled inside 'data:' URLs.") > -1)) throw err
|
|
4979
|
+
})
|
|
4980
|
+
}
|
|
4981
|
+
} catch (e) {
|
|
4982
|
+
console.warn('Warning during storage cleanup:', e.message)
|
|
4245
4983
|
}
|
|
4246
4984
|
}
|
|
4247
4985
|
|