codeceptjs 4.0.0-beta.7.esm-aria → 4.0.0-beta.8.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 +46 -3
- package/bin/codecept.js +9 -0
- package/bin/test-server.js +64 -0
- package/docs/webapi/click.mustache +5 -1
- package/lib/ai.js +66 -102
- package/lib/codecept.js +99 -24
- package/lib/command/generate.js +33 -1
- package/lib/command/init.js +7 -3
- package/lib/command/run-workers.js +31 -2
- package/lib/command/run.js +15 -0
- package/lib/command/workers/runTests.js +331 -58
- package/lib/config.js +16 -5
- package/lib/container.js +15 -13
- package/lib/effects.js +1 -1
- package/lib/element/WebElement.js +327 -0
- package/lib/event.js +10 -1
- package/lib/helper/AI.js +11 -11
- package/lib/helper/ApiDataFactory.js +34 -6
- package/lib/helper/Appium.js +156 -42
- package/lib/helper/GraphQL.js +3 -3
- package/lib/helper/GraphQLDataFactory.js +4 -4
- package/lib/helper/JSONResponse.js +48 -40
- package/lib/helper/Mochawesome.js +24 -2
- package/lib/helper/Playwright.js +841 -153
- package/lib/helper/Puppeteer.js +263 -67
- package/lib/helper/REST.js +21 -0
- package/lib/helper/WebDriver.js +105 -16
- package/lib/helper/clientscripts/PollyWebDriverExt.js +1 -1
- package/lib/helper/extras/PlaywrightReactVueLocator.js +52 -0
- package/lib/helper/extras/PlaywrightRestartOpts.js +12 -1
- package/lib/helper/network/actions.js +8 -6
- package/lib/listener/config.js +11 -3
- package/lib/listener/enhancedGlobalRetry.js +110 -0
- package/lib/listener/globalTimeout.js +19 -4
- package/lib/listener/helpers.js +8 -2
- package/lib/listener/retryEnhancer.js +85 -0
- package/lib/listener/steps.js +12 -0
- package/lib/mocha/asyncWrapper.js +13 -3
- package/lib/mocha/cli.js +1 -1
- package/lib/mocha/factory.js +3 -0
- package/lib/mocha/gherkin.js +1 -1
- package/lib/mocha/test.js +6 -0
- package/lib/mocha/ui.js +13 -0
- package/lib/output.js +62 -18
- package/lib/plugin/coverage.js +16 -3
- package/lib/plugin/enhancedRetryFailedStep.js +99 -0
- package/lib/plugin/htmlReporter.js +3648 -0
- package/lib/plugin/retryFailedStep.js +1 -0
- package/lib/plugin/stepByStepReport.js +1 -1
- package/lib/recorder.js +28 -3
- package/lib/result.js +100 -23
- package/lib/retryCoordinator.js +207 -0
- package/lib/step/base.js +1 -1
- package/lib/step/comment.js +2 -2
- package/lib/step/meta.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 +334 -0
- package/lib/utils/mask_data.js +47 -0
- package/lib/utils.js +87 -6
- package/lib/workerStorage.js +2 -1
- package/lib/workers.js +179 -23
- package/package.json +58 -47
- package/typings/index.d.ts +19 -7
- package/typings/promiseBasedTypes.d.ts +5525 -3759
- package/typings/types.d.ts +5791 -3781
package/lib/helper/Playwright.js
CHANGED
|
@@ -30,17 +30,25 @@ import ElementNotFound from './errors/ElementNotFound.js'
|
|
|
30
30
|
import RemoteBrowserConnectionRefused from './errors/RemoteBrowserConnectionRefused.js'
|
|
31
31
|
import Popup from './extras/Popup.js'
|
|
32
32
|
import Console from './extras/Console.js'
|
|
33
|
-
import {
|
|
33
|
+
import { findReact, findVue, findByPlaywrightLocator } from './extras/PlaywrightReactVueLocator.js'
|
|
34
|
+
import WebElement from '../element/WebElement.js'
|
|
34
35
|
|
|
35
36
|
let playwright
|
|
36
37
|
let perfTiming
|
|
37
38
|
let defaultSelectorEnginesInitialized = false
|
|
39
|
+
let registeredCustomLocatorStrategies = new Set()
|
|
40
|
+
let globalCustomLocatorStrategies = new Map()
|
|
41
|
+
|
|
42
|
+
// Use global object to track selector registration across workers
|
|
43
|
+
if (typeof global.__playwrightSelectorsRegistered === 'undefined') {
|
|
44
|
+
global.__playwrightSelectorsRegistered = false
|
|
45
|
+
}
|
|
38
46
|
|
|
39
47
|
const popupStore = new Popup()
|
|
40
48
|
const consoleLogStore = new Console()
|
|
41
49
|
const availableBrowsers = ['chromium', 'webkit', 'firefox', 'electron']
|
|
42
50
|
|
|
43
|
-
import { setRestartStrategy, restartsSession, restartsContext } from './extras/PlaywrightRestartOpts.js'
|
|
51
|
+
import { setRestartStrategy, restartsSession, restartsContext, restartsBrowser } from './extras/PlaywrightRestartOpts.js'
|
|
44
52
|
import { createValueEngine, createDisabledEngine } from './extras/PlaywrightPropEngine.js'
|
|
45
53
|
import { seeElementError, dontSeeElementError, dontSeeElementInDOMError, seeElementInDOMError } from './errors/ElementAssertion.js'
|
|
46
54
|
import { dontSeeTraffic, seeTraffic, grabRecordedNetworkTraffics, stopRecordingTraffic, flushNetworkTraffics } from './network/actions.js'
|
|
@@ -92,6 +100,13 @@ const pathSeparator = path.sep
|
|
|
92
100
|
* @prop {boolean} [highlightElement] - highlight the interacting elements. Default: false. Note: only activate under verbose mode (--verbose).
|
|
93
101
|
* @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).
|
|
94
102
|
* @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).
|
|
103
|
+
* @prop {object} [customLocatorStrategies] - custom locator strategies. An object with keys as strategy names and values as JavaScript functions. Example: `{ byRole: (selector, root) => { return root.querySelector(`[role="${selector}"]`) } }`
|
|
104
|
+
* @prop {string|object} [storageState] - Playwright storage state (path to JSON file or object)
|
|
105
|
+
* passed directly to `browser.newContext`.
|
|
106
|
+
* If a Scenario is declared with a `cookies` option (e.g. `Scenario('name', { cookies: [...] }, fn)`),
|
|
107
|
+
* those cookies are used instead and the configured `storageState` is ignored (no merge).
|
|
108
|
+
* May include session cookies, auth tokens, localStorage and (if captured with
|
|
109
|
+
* `grabStorageState({ indexedDB: true })`) IndexedDB data; treat as sensitive and do not commit.
|
|
95
110
|
*/
|
|
96
111
|
const config = {}
|
|
97
112
|
|
|
@@ -340,6 +355,28 @@ class Playwright extends Helper {
|
|
|
340
355
|
this.recordingWebSocketMessages = false
|
|
341
356
|
this.recordedWebSocketMessagesAtLeastOnce = false
|
|
342
357
|
this.cdpSession = null
|
|
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
|
|
372
|
+
this._customLocatorsRegistered = false
|
|
373
|
+
|
|
374
|
+
// Add custom locator strategies to global registry for early registration
|
|
375
|
+
if (this.customLocatorStrategies) {
|
|
376
|
+
for (const [strategyName, strategyFunction] of Object.entries(this.customLocatorStrategies)) {
|
|
377
|
+
globalCustomLocatorStrategies.set(strategyName, strategyFunction)
|
|
378
|
+
}
|
|
379
|
+
}
|
|
343
380
|
|
|
344
381
|
// Add test failure tracking to prevent false positives
|
|
345
382
|
this.testFailures = []
|
|
@@ -347,6 +384,11 @@ class Playwright extends Helper {
|
|
|
347
384
|
|
|
348
385
|
// override defaults with config
|
|
349
386
|
this._setConfig(config)
|
|
387
|
+
|
|
388
|
+
// pass storageState directly (string path or object) and let Playwright handle errors/missing file
|
|
389
|
+
if (typeof config.storageState !== 'undefined') {
|
|
390
|
+
this.storageState = config.storageState
|
|
391
|
+
}
|
|
350
392
|
}
|
|
351
393
|
|
|
352
394
|
_validateConfig(config) {
|
|
@@ -373,6 +415,7 @@ class Playwright extends Helper {
|
|
|
373
415
|
use: { actionTimeout: 0 },
|
|
374
416
|
ignoreHTTPSErrors: false, // Adding it here o that context can be set up to ignore the SSL errors,
|
|
375
417
|
highlightElement: false,
|
|
418
|
+
storageState: undefined,
|
|
376
419
|
}
|
|
377
420
|
|
|
378
421
|
process.env.testIdAttribute = 'data-testid'
|
|
@@ -479,19 +522,106 @@ class Playwright extends Helper {
|
|
|
479
522
|
}
|
|
480
523
|
}
|
|
481
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
|
+
|
|
482
535
|
// register an internal selector engine for reading value property of elements in a selector
|
|
483
|
-
if (defaultSelectorEnginesInitialized) return
|
|
484
|
-
defaultSelectorEnginesInitialized = true
|
|
485
536
|
try {
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
if (
|
|
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
|
|
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
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// Register all custom locator strategies from the global registry
|
|
567
|
+
for (const [strategyName, strategyFunction] of globalCustomLocatorStrategies.entries()) {
|
|
568
|
+
if (!registeredCustomLocatorStrategies.has(strategyName)) {
|
|
569
|
+
try {
|
|
570
|
+
// Create a selector engine factory function exactly like createValueEngine pattern
|
|
571
|
+
// Capture variables in closure to avoid reference issues
|
|
572
|
+
const createCustomEngine = ((name, func) => {
|
|
573
|
+
return () => {
|
|
574
|
+
return {
|
|
575
|
+
create() {
|
|
576
|
+
return null
|
|
577
|
+
},
|
|
578
|
+
query(root, selector) {
|
|
579
|
+
try {
|
|
580
|
+
if (!root) return null
|
|
581
|
+
const result = func(selector, root)
|
|
582
|
+
return Array.isArray(result) ? result[0] : result
|
|
583
|
+
} catch (error) {
|
|
584
|
+
console.warn(`Error in custom locator "${name}":`, error)
|
|
585
|
+
return null
|
|
586
|
+
}
|
|
587
|
+
},
|
|
588
|
+
queryAll(root, selector) {
|
|
589
|
+
try {
|
|
590
|
+
if (!root) return []
|
|
591
|
+
const result = func(selector, root)
|
|
592
|
+
return Array.isArray(result) ? result : result ? [result] : []
|
|
593
|
+
} catch (error) {
|
|
594
|
+
console.warn(`Error in custom locator "${name}":`, error)
|
|
595
|
+
return []
|
|
596
|
+
}
|
|
597
|
+
},
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
})(strategyName, strategyFunction)
|
|
601
|
+
|
|
602
|
+
await playwright.selectors.register(strategyName, createCustomEngine)
|
|
603
|
+
registeredCustomLocatorStrategies.add(strategyName)
|
|
604
|
+
} catch (error) {
|
|
605
|
+
if (!error.message.includes('already registered')) {
|
|
606
|
+
console.warn(`Failed to register custom locator strategy '${strategyName}':`, error)
|
|
607
|
+
} else {
|
|
608
|
+
console.log(`Custom locator strategy '${strategyName}' already registered`)
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
}
|
|
489
613
|
} catch (e) {
|
|
490
614
|
console.warn(e)
|
|
491
615
|
}
|
|
492
616
|
}
|
|
493
617
|
|
|
494
618
|
_beforeSuite() {
|
|
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
|
+
|
|
495
625
|
// Start browser if not manually started and not already running
|
|
496
626
|
// Browser should start in singleton mode (restart: false) or when restart strategy is enabled
|
|
497
627
|
if (!this.options.manualStart && !this.isRunning) {
|
|
@@ -501,6 +631,12 @@ class Playwright extends Helper {
|
|
|
501
631
|
}
|
|
502
632
|
|
|
503
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
|
+
|
|
504
640
|
this.currentRunningTest = test
|
|
505
641
|
|
|
506
642
|
// Reset failure tracking for each test to prevent false positives
|
|
@@ -526,7 +662,12 @@ class Playwright extends Helper {
|
|
|
526
662
|
},
|
|
527
663
|
})
|
|
528
664
|
|
|
665
|
+
// Start browser if needed (initial start or browser restart strategy)
|
|
529
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
|
+
}
|
|
530
671
|
|
|
531
672
|
this.isAuthenticated = false
|
|
532
673
|
if (this.isElectron) {
|
|
@@ -557,8 +698,7 @@ class Playwright extends Helper {
|
|
|
557
698
|
|
|
558
699
|
// load pre-saved cookies
|
|
559
700
|
if (test?.opts?.cookies) contextOptions.storageState = { cookies: test.opts.cookies }
|
|
560
|
-
|
|
561
|
-
if (this.storageState) contextOptions.storageState = this.storageState
|
|
701
|
+
else if (this.storageState) contextOptions.storageState = this.storageState
|
|
562
702
|
if (this.options.userAgent) contextOptions.userAgent = this.options.userAgent
|
|
563
703
|
if (this.options.locale) contextOptions.locale = this.options.locale
|
|
564
704
|
if (this.options.colorScheme) contextOptions.colorScheme = this.options.colorScheme
|
|
@@ -573,7 +713,20 @@ class Playwright extends Helper {
|
|
|
573
713
|
}
|
|
574
714
|
}
|
|
575
715
|
this.debugSection('New Session', JSON.stringify(this.contextOptions))
|
|
576
|
-
|
|
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
|
+
}
|
|
577
730
|
}
|
|
578
731
|
}
|
|
579
732
|
|
|
@@ -614,6 +767,9 @@ class Playwright extends Helper {
|
|
|
614
767
|
async _after() {
|
|
615
768
|
if (!this.isRunning) return
|
|
616
769
|
|
|
770
|
+
// Clear popup state to prevent leakage between tests
|
|
771
|
+
popupStore.clear()
|
|
772
|
+
|
|
617
773
|
if (this.isElectron) {
|
|
618
774
|
try {
|
|
619
775
|
this.browser.close()
|
|
@@ -628,6 +784,31 @@ class Playwright extends Helper {
|
|
|
628
784
|
return refreshContextSession.bind(this)()
|
|
629
785
|
}
|
|
630
786
|
|
|
787
|
+
if (restartsBrowser()) {
|
|
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
|
|
810
|
+
}
|
|
811
|
+
|
|
631
812
|
// close other sessions with timeout protection, but only if restartsContext() is true
|
|
632
813
|
if (restartsContext()) {
|
|
633
814
|
try {
|
|
@@ -653,15 +834,25 @@ class Playwright extends Helper {
|
|
|
653
834
|
}
|
|
654
835
|
|
|
655
836
|
async _afterSuite() {
|
|
656
|
-
//
|
|
657
|
-
|
|
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) {
|
|
658
841
|
try {
|
|
659
|
-
|
|
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
|
+
])
|
|
660
847
|
} catch (e) {
|
|
661
848
|
console.warn('Warning during suite cleanup:', e.message)
|
|
662
849
|
// Track suite cleanup failures
|
|
663
850
|
this.hasCleanupError = true
|
|
664
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
|
|
665
856
|
} finally {
|
|
666
857
|
this.isRunning = false
|
|
667
858
|
}
|
|
@@ -737,7 +928,7 @@ class Playwright extends Helper {
|
|
|
737
928
|
}
|
|
738
929
|
|
|
739
930
|
async _finishTest() {
|
|
740
|
-
if ((restartsSession() || restartsContext()) && this.isRunning) {
|
|
931
|
+
if ((restartsSession() || restartsContext() || restartsBrowser()) && this.isRunning) {
|
|
741
932
|
try {
|
|
742
933
|
await Promise.race([this._stopBrowser(), new Promise((_, reject) => setTimeout(() => reject(new Error('Test finish timeout')), 10000))])
|
|
743
934
|
} catch (e) {
|
|
@@ -762,17 +953,32 @@ class Playwright extends Helper {
|
|
|
762
953
|
// Final cleanup when test run completes
|
|
763
954
|
if (this.isRunning) {
|
|
764
955
|
try {
|
|
765
|
-
|
|
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
|
+
])
|
|
766
961
|
} catch (e) {
|
|
767
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
|
|
768
967
|
}
|
|
769
968
|
} else {
|
|
770
969
|
// Check if we still have a browser object despite isRunning being false
|
|
771
970
|
if (this.browser) {
|
|
772
971
|
try {
|
|
773
|
-
|
|
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
|
+
])
|
|
774
977
|
} catch (e) {
|
|
775
978
|
console.warn('Warning during forced cleanup:', e.message)
|
|
979
|
+
// Force cleanup on timeout
|
|
980
|
+
this.browser = null
|
|
981
|
+
this.browserContext = null
|
|
776
982
|
}
|
|
777
983
|
}
|
|
778
984
|
}
|
|
@@ -950,6 +1156,9 @@ class Playwright extends Helper {
|
|
|
950
1156
|
try {
|
|
951
1157
|
this.page.removeAllListeners('crash')
|
|
952
1158
|
this.page.removeAllListeners('dialog')
|
|
1159
|
+
this.page.removeAllListeners('load')
|
|
1160
|
+
this.page.removeAllListeners('console')
|
|
1161
|
+
this.page.removeAllListeners('requestfinished')
|
|
953
1162
|
} catch (e) {
|
|
954
1163
|
console.warn('Warning cleaning previous page listeners:', e.message)
|
|
955
1164
|
}
|
|
@@ -1038,6 +1247,12 @@ class Playwright extends Helper {
|
|
|
1038
1247
|
}
|
|
1039
1248
|
|
|
1040
1249
|
async _startBrowser() {
|
|
1250
|
+
// Ensure custom locator strategies are registered before browser launch
|
|
1251
|
+
// Only init once globally to avoid selector re-registration in workers
|
|
1252
|
+
if (!defaultSelectorEnginesInitialized) {
|
|
1253
|
+
await this._init()
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1041
1256
|
if (this.isElectron) {
|
|
1042
1257
|
this.browser = await playwright._electron.launch(this.playwrightOptions)
|
|
1043
1258
|
} else if (this.isRemoteBrowser && this.isCDPConnection) {
|
|
@@ -1073,6 +1288,30 @@ class Playwright extends Helper {
|
|
|
1073
1288
|
return this.browser
|
|
1074
1289
|
}
|
|
1075
1290
|
|
|
1291
|
+
_lookupCustomLocator(customStrategy) {
|
|
1292
|
+
if (typeof this.customLocatorStrategies !== 'object' || this.customLocatorStrategies === null) {
|
|
1293
|
+
return null
|
|
1294
|
+
}
|
|
1295
|
+
const strategy = this.customLocatorStrategies[customStrategy]
|
|
1296
|
+
return typeof strategy === 'function' ? strategy : null
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
_isCustomLocator(locator) {
|
|
1300
|
+
const locatorObj = new Locator(locator)
|
|
1301
|
+
if (locatorObj.isCustom()) {
|
|
1302
|
+
const customLocator = this._lookupCustomLocator(locatorObj.type)
|
|
1303
|
+
if (customLocator) {
|
|
1304
|
+
return true
|
|
1305
|
+
}
|
|
1306
|
+
throw new Error('Please define "customLocatorStrategies" as an Object and the Locator Strategy as a "function".')
|
|
1307
|
+
}
|
|
1308
|
+
return false
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
_isCustomLocatorStrategyDefined() {
|
|
1312
|
+
return !!(this.customLocatorStrategies && Object.keys(this.customLocatorStrategies).length > 0)
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1076
1315
|
/**
|
|
1077
1316
|
* Create a new browser context with a page. \
|
|
1078
1317
|
* Usually it should be run from a custom helper after call of `_startBrowser()`
|
|
@@ -1083,11 +1322,64 @@ class Playwright extends Helper {
|
|
|
1083
1322
|
throw new Error('Browser not started. Call _startBrowser() first or disable manualStart option.')
|
|
1084
1323
|
}
|
|
1085
1324
|
this.browserContext = await this.browser.newContext(contextOptions)
|
|
1325
|
+
|
|
1326
|
+
// Register custom locator strategies for this context
|
|
1327
|
+
await this._registerCustomLocatorStrategies()
|
|
1328
|
+
|
|
1086
1329
|
const page = await this.browserContext.newPage()
|
|
1087
1330
|
targetCreatedHandler.call(this, page)
|
|
1088
1331
|
await this._setPage(page)
|
|
1089
1332
|
}
|
|
1090
1333
|
|
|
1334
|
+
async _registerCustomLocatorStrategies() {
|
|
1335
|
+
if (!this.customLocatorStrategies) return
|
|
1336
|
+
|
|
1337
|
+
for (const [strategyName, strategyFunction] of Object.entries(this.customLocatorStrategies)) {
|
|
1338
|
+
if (!registeredCustomLocatorStrategies.has(strategyName)) {
|
|
1339
|
+
try {
|
|
1340
|
+
const createCustomEngine = ((name, func) => {
|
|
1341
|
+
return () => {
|
|
1342
|
+
return {
|
|
1343
|
+
create(root, target) {
|
|
1344
|
+
return null
|
|
1345
|
+
},
|
|
1346
|
+
query(root, selector) {
|
|
1347
|
+
try {
|
|
1348
|
+
if (!root) return null
|
|
1349
|
+
const result = func(selector, root)
|
|
1350
|
+
return Array.isArray(result) ? result[0] : result
|
|
1351
|
+
} catch (error) {
|
|
1352
|
+
console.warn(`Error in custom locator "${name}":`, error)
|
|
1353
|
+
return null
|
|
1354
|
+
}
|
|
1355
|
+
},
|
|
1356
|
+
queryAll(root, selector) {
|
|
1357
|
+
try {
|
|
1358
|
+
if (!root) return []
|
|
1359
|
+
const result = func(selector, root)
|
|
1360
|
+
return Array.isArray(result) ? result : result ? [result] : []
|
|
1361
|
+
} catch (error) {
|
|
1362
|
+
console.warn(`Error in custom locator "${name}":`, error)
|
|
1363
|
+
return []
|
|
1364
|
+
}
|
|
1365
|
+
},
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
})(strategyName, strategyFunction)
|
|
1369
|
+
|
|
1370
|
+
await playwright.selectors.register(strategyName, createCustomEngine)
|
|
1371
|
+
registeredCustomLocatorStrategies.add(strategyName)
|
|
1372
|
+
} catch (error) {
|
|
1373
|
+
if (!error.message.includes('already registered')) {
|
|
1374
|
+
console.warn(`Failed to register custom locator strategy '${strategyName}':`, error)
|
|
1375
|
+
} else {
|
|
1376
|
+
console.log(`Custom locator strategy '${strategyName}' already registered`)
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1091
1383
|
_getType() {
|
|
1092
1384
|
return this.browser._type
|
|
1093
1385
|
}
|
|
@@ -1098,57 +1390,43 @@ class Playwright extends Helper {
|
|
|
1098
1390
|
this.context = null
|
|
1099
1391
|
this.frame = null
|
|
1100
1392
|
popupStore.clear()
|
|
1101
|
-
|
|
1102
|
-
//
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
this.browser.removeAllListeners(
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
const pages = this.browserContext.pages()
|
|
1109
|
-
for (const page of pages) {
|
|
1110
|
-
try {
|
|
1111
|
-
page.removeAllListeners('crash')
|
|
1112
|
-
page.removeAllListeners('dialog')
|
|
1113
|
-
} catch (e) {
|
|
1114
|
-
console.warn('Warning cleaning page listeners:', e.message)
|
|
1115
|
-
}
|
|
1116
|
-
}
|
|
1117
|
-
}
|
|
1118
|
-
}
|
|
1119
|
-
} catch (e) {
|
|
1120
|
-
console.warn('Warning cleaning event listeners:', e.message)
|
|
1121
|
-
}
|
|
1122
|
-
|
|
1123
|
-
try {
|
|
1124
|
-
if (this.browserContext) {
|
|
1125
|
-
await Promise.race([this.browserContext.close(), new Promise((_, reject) => setTimeout(() => reject(new Error('Context close timeout')), 3000))])
|
|
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
|
|
1126
1400
|
}
|
|
1127
|
-
} catch (error) {
|
|
1128
|
-
console.warn('Failed to close browser context:', error.message)
|
|
1129
1401
|
}
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
await
|
|
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
|
|
1134
1408
|
}
|
|
1135
|
-
} catch (error) {
|
|
1136
|
-
console.warn('Failed to close browser:', error.message)
|
|
1137
1409
|
}
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1410
|
+
this.browserContext = null
|
|
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
|
|
1143
1427
|
}
|
|
1144
|
-
} catch (e) {
|
|
1145
|
-
// Silently ignore process kill errors
|
|
1146
1428
|
}
|
|
1147
|
-
|
|
1148
|
-
// Ensure cleanup is complete
|
|
1149
1429
|
this.browser = null
|
|
1150
|
-
this.browserContext = null
|
|
1151
|
-
this.page = null
|
|
1152
1430
|
this.isRunning = false
|
|
1153
1431
|
}
|
|
1154
1432
|
|
|
@@ -1225,8 +1503,9 @@ class Playwright extends Helper {
|
|
|
1225
1503
|
throw new Error('Cannot open pages inside an Electron container')
|
|
1226
1504
|
}
|
|
1227
1505
|
|
|
1228
|
-
// Prevent navigation attempts when browser is
|
|
1229
|
-
|
|
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)) {
|
|
1230
1509
|
throw new Error('Cannot navigate: browser is not running or has been closed')
|
|
1231
1510
|
}
|
|
1232
1511
|
|
|
@@ -1260,7 +1539,21 @@ class Playwright extends Helper {
|
|
|
1260
1539
|
acceptDownloads: true,
|
|
1261
1540
|
...this.options.emulate,
|
|
1262
1541
|
}
|
|
1263
|
-
|
|
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
|
+
}
|
|
1264
1557
|
}
|
|
1265
1558
|
|
|
1266
1559
|
let pages
|
|
@@ -1450,20 +1743,21 @@ class Playwright extends Helper {
|
|
|
1450
1743
|
async dragAndDrop(srcElement, destElement, options) {
|
|
1451
1744
|
const src = new Locator(srcElement)
|
|
1452
1745
|
const dst = new Locator(destElement)
|
|
1746
|
+
const context = await this._getContext()
|
|
1453
1747
|
|
|
1454
1748
|
if (options) {
|
|
1455
|
-
return
|
|
1749
|
+
return context.dragAndDrop(buildLocatorString(src), buildLocatorString(dst), options)
|
|
1456
1750
|
}
|
|
1457
1751
|
|
|
1458
1752
|
const _smallWaitInMs = 600
|
|
1459
|
-
await
|
|
1753
|
+
await context.locator(buildLocatorString(src)).hover()
|
|
1460
1754
|
await this.page.mouse.down()
|
|
1461
1755
|
await this.page.waitForTimeout(_smallWaitInMs)
|
|
1462
1756
|
|
|
1463
|
-
const destElBox = await
|
|
1757
|
+
const destElBox = await context.locator(buildLocatorString(dst)).boundingBox()
|
|
1464
1758
|
|
|
1465
1759
|
await this.page.mouse.move(destElBox.x + destElBox.width / 2, destElBox.y + destElBox.height / 2)
|
|
1466
|
-
await
|
|
1760
|
+
await context.locator(buildLocatorString(dst)).hover({ position: { x: 10, y: 10 } })
|
|
1467
1761
|
await this.page.waitForTimeout(_smallWaitInMs)
|
|
1468
1762
|
await this.page.mouse.up()
|
|
1469
1763
|
}
|
|
@@ -1603,9 +1897,9 @@ class Playwright extends Helper {
|
|
|
1603
1897
|
async _locate(locator) {
|
|
1604
1898
|
const context = await this._getContext()
|
|
1605
1899
|
|
|
1606
|
-
if (this.frame) return findElements(this.frame, locator)
|
|
1900
|
+
if (this.frame) return findElements.call(this, this.frame, locator)
|
|
1607
1901
|
|
|
1608
|
-
const els = await findElements(context, locator)
|
|
1902
|
+
const els = await findElements.call(this, context, locator)
|
|
1609
1903
|
|
|
1610
1904
|
if (store.debugMode) {
|
|
1611
1905
|
const previewElements = els.slice(0, 3)
|
|
@@ -1679,7 +1973,8 @@ class Playwright extends Helper {
|
|
|
1679
1973
|
*
|
|
1680
1974
|
*/
|
|
1681
1975
|
async grabWebElements(locator) {
|
|
1682
|
-
|
|
1976
|
+
const elements = await this._locate(locator)
|
|
1977
|
+
return elements.map(element => new WebElement(element, this))
|
|
1683
1978
|
}
|
|
1684
1979
|
|
|
1685
1980
|
/**
|
|
@@ -1687,7 +1982,8 @@ class Playwright extends Helper {
|
|
|
1687
1982
|
*
|
|
1688
1983
|
*/
|
|
1689
1984
|
async grabWebElement(locator) {
|
|
1690
|
-
|
|
1985
|
+
const element = await this._locateElement(locator)
|
|
1986
|
+
return new WebElement(element, this)
|
|
1691
1987
|
}
|
|
1692
1988
|
|
|
1693
1989
|
/**
|
|
@@ -1911,7 +2207,7 @@ class Playwright extends Helper {
|
|
|
1911
2207
|
* ```
|
|
1912
2208
|
*
|
|
1913
2209
|
*/
|
|
1914
|
-
async click(locator, context = null, options = {}) {
|
|
2210
|
+
async click(locator = '//body', context = null, options = {}) {
|
|
1915
2211
|
return proceedClick.call(this, locator, context, options)
|
|
1916
2212
|
}
|
|
1917
2213
|
|
|
@@ -1945,6 +2241,49 @@ class Playwright extends Helper {
|
|
|
1945
2241
|
return proceedClick.call(this, locator, context, { button: 'right' })
|
|
1946
2242
|
}
|
|
1947
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
|
+
|
|
1948
2287
|
/**
|
|
1949
2288
|
*
|
|
1950
2289
|
* [Additional options](https://playwright.dev/docs/api/class-elementhandle#element-handle-check) for check available as 3rd argument.
|
|
@@ -2020,7 +2359,7 @@ class Playwright extends Helper {
|
|
|
2020
2359
|
|
|
2021
2360
|
/**
|
|
2022
2361
|
*
|
|
2023
|
-
* _Note:_ Shortcuts like `'Meta'` + `'A'` do not work on macOS ([
|
|
2362
|
+
* _Note:_ Shortcuts like `'Meta'` + `'A'` do not work on macOS ([puppeteer/puppeteer#1313](https://github.com/puppeteer/puppeteer/issues/1313)).
|
|
2024
2363
|
*
|
|
2025
2364
|
* {{> pressKeyWithKeyNormalization }}
|
|
2026
2365
|
*/
|
|
@@ -2053,11 +2392,15 @@ class Playwright extends Helper {
|
|
|
2053
2392
|
* {{> type }}
|
|
2054
2393
|
*/
|
|
2055
2394
|
async type(keys, delay = null) {
|
|
2395
|
+
// Always use page.keyboard.type for any string (including single character and national characters).
|
|
2056
2396
|
if (!Array.isArray(keys)) {
|
|
2057
2397
|
keys = keys.toString()
|
|
2058
|
-
|
|
2398
|
+
const typeDelay = typeof delay === 'number' ? delay : this.options.pressKeyDelay
|
|
2399
|
+
await this.page.keyboard.type(keys, { delay: typeDelay })
|
|
2400
|
+
return
|
|
2059
2401
|
}
|
|
2060
2402
|
|
|
2403
|
+
// For array input, treat each as a key press to keep working combinations such as ['Control', 'A'] or ['T', 'e', 's', 't'].
|
|
2061
2404
|
for (const key of keys) {
|
|
2062
2405
|
await this.page.keyboard.press(key)
|
|
2063
2406
|
if (delay) await this.wait(delay / 1000)
|
|
@@ -2360,6 +2703,30 @@ class Playwright extends Helper {
|
|
|
2360
2703
|
}
|
|
2361
2704
|
}
|
|
2362
2705
|
|
|
2706
|
+
/**
|
|
2707
|
+
* Grab the current storage state (cookies, localStorage, etc.) via Playwright's `browserContext.storageState()`.
|
|
2708
|
+
* Returns the raw object that Playwright provides.
|
|
2709
|
+
*
|
|
2710
|
+
* Security: The returned object can contain authentication tokens, session cookies
|
|
2711
|
+
* and (when `indexedDB: true` is used) data that may include user PII. Treat it as a secret.
|
|
2712
|
+
* Avoid committing it to source control and prefer storing it in a protected secrets store / CI artifact vault.
|
|
2713
|
+
*
|
|
2714
|
+
* @param {object} [options]
|
|
2715
|
+
* @param {boolean} [options.indexedDB] set to true to include IndexedDB in snapshot (Playwright >=1.51)
|
|
2716
|
+
*
|
|
2717
|
+
* ```js
|
|
2718
|
+
* // basic usage
|
|
2719
|
+
* const state = await I.grabStorageState();
|
|
2720
|
+
* require('fs').writeFileSync('authState.json', JSON.stringify(state));
|
|
2721
|
+
*
|
|
2722
|
+
* // include IndexedDB when using Firebase Auth, etc.
|
|
2723
|
+
* const stateWithIDB = await I.grabStorageState({ indexedDB: true });
|
|
2724
|
+
* ```
|
|
2725
|
+
*/
|
|
2726
|
+
async grabStorageState(options = {}) {
|
|
2727
|
+
return this.browserContext.storageState(options)
|
|
2728
|
+
}
|
|
2729
|
+
|
|
2363
2730
|
/**
|
|
2364
2731
|
* {{> clearCookie }}
|
|
2365
2732
|
*/
|
|
@@ -2409,11 +2776,25 @@ class Playwright extends Helper {
|
|
|
2409
2776
|
* @param {*} locator
|
|
2410
2777
|
*/
|
|
2411
2778
|
_contextLocator(locator) {
|
|
2412
|
-
|
|
2779
|
+
const locatorObj = new Locator(locator, 'css')
|
|
2780
|
+
|
|
2781
|
+
// Handle custom locators differently
|
|
2782
|
+
if (locatorObj.isCustom()) {
|
|
2783
|
+
return buildCustomLocatorString(locatorObj)
|
|
2784
|
+
}
|
|
2785
|
+
|
|
2786
|
+
locator = buildLocatorString(locatorObj)
|
|
2413
2787
|
|
|
2414
2788
|
if (this.contextLocator) {
|
|
2415
|
-
const
|
|
2416
|
-
|
|
2789
|
+
const contextLocatorObj = new Locator(this.contextLocator, 'css')
|
|
2790
|
+
if (contextLocatorObj.isCustom()) {
|
|
2791
|
+
// For custom context locators, we can't use the >> syntax
|
|
2792
|
+
// Instead, we'll need to handle this differently in the calling methods
|
|
2793
|
+
return locator
|
|
2794
|
+
} else {
|
|
2795
|
+
const contextLocator = buildLocatorString(contextLocatorObj)
|
|
2796
|
+
locator = `${contextLocator} >> ${locator}`
|
|
2797
|
+
}
|
|
2417
2798
|
}
|
|
2418
2799
|
|
|
2419
2800
|
return locator
|
|
@@ -2424,30 +2805,44 @@ class Playwright extends Helper {
|
|
|
2424
2805
|
*
|
|
2425
2806
|
*/
|
|
2426
2807
|
async grabTextFrom(locator) {
|
|
2427
|
-
|
|
2428
|
-
|
|
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
|
+
|
|
2819
|
+
const locatorObj = new Locator(locator, 'css')
|
|
2429
2820
|
|
|
2430
|
-
if (
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2821
|
+
if (locatorObj.isCustom()) {
|
|
2822
|
+
// For custom locators, find the element first
|
|
2823
|
+
const elements = await findCustomElements.call(this, this.page, locatorObj)
|
|
2824
|
+
if (elements.length === 0) {
|
|
2825
|
+
throw new Error(`Element not found: ${locatorObj.toString()}`)
|
|
2826
|
+
}
|
|
2827
|
+
const text = await elements[0].textContent()
|
|
2828
|
+
assertElementExists(text, locatorObj.toString())
|
|
2434
2829
|
this.debugSection('Text', text)
|
|
2435
2830
|
return text
|
|
2436
|
-
}
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2831
|
+
} else {
|
|
2832
|
+
locator = this._contextLocator(locator)
|
|
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
|
|
2445
2844
|
}
|
|
2446
|
-
throw err
|
|
2447
2845
|
}
|
|
2448
|
-
assertElementExists(text, contextAwareLocator)
|
|
2449
|
-
this.debugSection('Text', text)
|
|
2450
|
-
return text
|
|
2451
2846
|
}
|
|
2452
2847
|
|
|
2453
2848
|
/**
|
|
@@ -2460,7 +2855,6 @@ class Playwright extends Helper {
|
|
|
2460
2855
|
for (const el of els) {
|
|
2461
2856
|
texts.push(await el.innerText())
|
|
2462
2857
|
}
|
|
2463
|
-
this.debug(`Matched ${els.length} elements`)
|
|
2464
2858
|
return texts
|
|
2465
2859
|
}
|
|
2466
2860
|
|
|
@@ -2479,7 +2873,6 @@ class Playwright extends Helper {
|
|
|
2479
2873
|
*/
|
|
2480
2874
|
async grabValueFromAll(locator) {
|
|
2481
2875
|
const els = await findFields.call(this, locator)
|
|
2482
|
-
this.debug(`Matched ${els.length} elements`)
|
|
2483
2876
|
return Promise.all(els.map(el => el.inputValue()))
|
|
2484
2877
|
}
|
|
2485
2878
|
|
|
@@ -2498,7 +2891,6 @@ class Playwright extends Helper {
|
|
|
2498
2891
|
*/
|
|
2499
2892
|
async grabHTMLFromAll(locator) {
|
|
2500
2893
|
const els = await this._locate(locator)
|
|
2501
|
-
this.debug(`Matched ${els.length} elements`)
|
|
2502
2894
|
return Promise.all(els.map(el => el.innerHTML()))
|
|
2503
2895
|
}
|
|
2504
2896
|
|
|
@@ -2519,7 +2911,6 @@ class Playwright extends Helper {
|
|
|
2519
2911
|
*/
|
|
2520
2912
|
async grabCssPropertyFromAll(locator, cssProperty) {
|
|
2521
2913
|
const els = await this._locate(locator)
|
|
2522
|
-
this.debug(`Matched ${els.length} elements`)
|
|
2523
2914
|
const cssValues = await Promise.all(els.map(el => el.evaluate((el, cssProperty) => getComputedStyle(el).getPropertyValue(cssProperty), cssProperty)))
|
|
2524
2915
|
|
|
2525
2916
|
return cssValues
|
|
@@ -2630,7 +3021,6 @@ class Playwright extends Helper {
|
|
|
2630
3021
|
*/
|
|
2631
3022
|
async grabAttributeFromAll(locator, attr) {
|
|
2632
3023
|
const els = await this._locate(locator)
|
|
2633
|
-
this.debug(`Matched ${els.length} elements`)
|
|
2634
3024
|
const array = []
|
|
2635
3025
|
|
|
2636
3026
|
for (let index = 0; index < els.length; index++) {
|
|
@@ -2640,6 +3030,33 @@ class Playwright extends Helper {
|
|
|
2640
3030
|
return array
|
|
2641
3031
|
}
|
|
2642
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
|
+
|
|
2643
3060
|
/**
|
|
2644
3061
|
* {{> saveElementScreenshot }}
|
|
2645
3062
|
*
|
|
@@ -2647,16 +3064,10 @@ class Playwright extends Helper {
|
|
|
2647
3064
|
async saveElementScreenshot(locator, fileName) {
|
|
2648
3065
|
const outputFile = screenshotOutputFolder(fileName)
|
|
2649
3066
|
|
|
2650
|
-
|
|
2651
|
-
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
this.debug(`Screenshot of ${new Locator(locator)} element has been saved to ${outputFile}`)
|
|
2655
|
-
return elem.screenshot({ path: outputFile, type: 'png' })
|
|
2656
|
-
} catch (err) {
|
|
2657
|
-
this.debug(`Failed to take element screenshot: ${err.message}`)
|
|
2658
|
-
throw err
|
|
2659
|
-
}
|
|
3067
|
+
const res = await this._locateElement(locator)
|
|
3068
|
+
assertElementExists(res, locator)
|
|
3069
|
+
const elem = res
|
|
3070
|
+
return elem.screenshot({ path: outputFile, type: 'png' })
|
|
2660
3071
|
}
|
|
2661
3072
|
|
|
2662
3073
|
/**
|
|
@@ -2795,15 +3206,19 @@ class Playwright extends Helper {
|
|
|
2795
3206
|
if (this.options.recordVideo && this.page && this.page.video()) {
|
|
2796
3207
|
test.artifacts.video = saveVideoForPage(this.page, `${test.title}.failed`)
|
|
2797
3208
|
for (const sessionName in this.sessionPages) {
|
|
2798
|
-
|
|
3209
|
+
if (sessionName === '') continue
|
|
3210
|
+
test.artifacts[`video_${sessionName}`] = saveVideoForPage(this.sessionPages[sessionName], `${sessionName}_${test.title}.failed`)
|
|
2799
3211
|
}
|
|
2800
3212
|
}
|
|
2801
3213
|
|
|
2802
3214
|
if (this.options.trace) {
|
|
2803
3215
|
test.artifacts.trace = await saveTraceForContext(this.browserContext, `${test.title}.failed`)
|
|
2804
3216
|
for (const sessionName in this.sessionPages) {
|
|
2805
|
-
if (
|
|
2806
|
-
|
|
3217
|
+
if (sessionName === '') continue
|
|
3218
|
+
const sessionPage = this.sessionPages[sessionName]
|
|
3219
|
+
const sessionContext = sessionPage.context()
|
|
3220
|
+
if (!sessionContext || !sessionContext.tracing) continue
|
|
3221
|
+
test.artifacts[`trace_${sessionName}`] = await saveTraceForContext(sessionContext, `${sessionName}_${test.title}.failed`)
|
|
2807
3222
|
}
|
|
2808
3223
|
}
|
|
2809
3224
|
|
|
@@ -2817,7 +3232,8 @@ class Playwright extends Helper {
|
|
|
2817
3232
|
if (this.options.keepVideoForPassedTests) {
|
|
2818
3233
|
test.artifacts.video = saveVideoForPage(this.page, `${test.title}.passed`)
|
|
2819
3234
|
for (const sessionName of Object.keys(this.sessionPages)) {
|
|
2820
|
-
|
|
3235
|
+
if (sessionName === '') continue
|
|
3236
|
+
test.artifacts[`video_${sessionName}`] = saveVideoForPage(this.sessionPages[sessionName], `${sessionName}_${test.title}.passed`)
|
|
2821
3237
|
}
|
|
2822
3238
|
} else {
|
|
2823
3239
|
this.page
|
|
@@ -2832,8 +3248,11 @@ class Playwright extends Helper {
|
|
|
2832
3248
|
if (this.options.trace) {
|
|
2833
3249
|
test.artifacts.trace = await saveTraceForContext(this.browserContext, `${test.title}.passed`)
|
|
2834
3250
|
for (const sessionName in this.sessionPages) {
|
|
2835
|
-
if (
|
|
2836
|
-
|
|
3251
|
+
if (sessionName === '') continue
|
|
3252
|
+
const sessionPage = this.sessionPages[sessionName]
|
|
3253
|
+
const sessionContext = sessionPage.context()
|
|
3254
|
+
if (!sessionContext || !sessionContext.tracing) continue
|
|
3255
|
+
test.artifacts[`trace_${sessionName}`] = await saveTraceForContext(sessionContext, `${sessionName}_${test.title}.passed`)
|
|
2837
3256
|
}
|
|
2838
3257
|
}
|
|
2839
3258
|
} else {
|
|
@@ -2987,7 +3406,16 @@ class Playwright extends Helper {
|
|
|
2987
3406
|
|
|
2988
3407
|
const context = await this._getContext()
|
|
2989
3408
|
try {
|
|
2990
|
-
|
|
3409
|
+
if (locator.isCustom()) {
|
|
3410
|
+
// For custom locators, we need to use our custom element finding logic
|
|
3411
|
+
const elements = await findCustomElements.call(this, context, locator)
|
|
3412
|
+
if (elements.length === 0) {
|
|
3413
|
+
throw new Error(`Custom locator ${locator.type}=${locator.value} not found`)
|
|
3414
|
+
}
|
|
3415
|
+
await elements[0].waitFor({ timeout: waitTimeout, state: 'attached' })
|
|
3416
|
+
} else {
|
|
3417
|
+
await context.locator(buildLocatorString(locator)).first().waitFor({ timeout: waitTimeout, state: 'attached' })
|
|
3418
|
+
}
|
|
2991
3419
|
} catch (e) {
|
|
2992
3420
|
throw new Error(`element (${locator.toString()}) still not present on page after ${waitTimeout / 1000} sec\n${e.message}`)
|
|
2993
3421
|
}
|
|
@@ -3001,9 +3429,30 @@ class Playwright extends Helper {
|
|
|
3001
3429
|
async waitForVisible(locator, sec) {
|
|
3002
3430
|
const waitTimeout = sec ? sec * 1000 : this.options.waitForTimeout
|
|
3003
3431
|
locator = new Locator(locator, 'css')
|
|
3432
|
+
|
|
3004
3433
|
const context = await this._getContext()
|
|
3005
3434
|
let count = 0
|
|
3006
3435
|
|
|
3436
|
+
// Handle custom locators
|
|
3437
|
+
if (locator.isCustom()) {
|
|
3438
|
+
let waiter
|
|
3439
|
+
do {
|
|
3440
|
+
const elements = await findCustomElements.call(this, context, locator)
|
|
3441
|
+
if (elements.length > 0) {
|
|
3442
|
+
waiter = await elements[0].isVisible()
|
|
3443
|
+
} else {
|
|
3444
|
+
waiter = false
|
|
3445
|
+
}
|
|
3446
|
+
if (!waiter) {
|
|
3447
|
+
await this.wait(1)
|
|
3448
|
+
count += 1000
|
|
3449
|
+
}
|
|
3450
|
+
} while (!waiter && count <= waitTimeout)
|
|
3451
|
+
|
|
3452
|
+
if (!waiter) throw new Error(`element (${locator.toString()}) still not visible after ${waitTimeout / 1000} sec.`)
|
|
3453
|
+
return
|
|
3454
|
+
}
|
|
3455
|
+
|
|
3007
3456
|
// we have this as https://github.com/microsoft/playwright/issues/26829 is not yet implemented
|
|
3008
3457
|
let waiter
|
|
3009
3458
|
if (this.frame) {
|
|
@@ -3030,6 +3479,7 @@ class Playwright extends Helper {
|
|
|
3030
3479
|
async waitForInvisible(locator, sec) {
|
|
3031
3480
|
const waitTimeout = sec ? sec * 1000 : this.options.waitForTimeout
|
|
3032
3481
|
locator = new Locator(locator, 'css')
|
|
3482
|
+
|
|
3033
3483
|
const context = await this._getContext()
|
|
3034
3484
|
let waiter
|
|
3035
3485
|
let count = 0
|
|
@@ -3060,6 +3510,7 @@ class Playwright extends Helper {
|
|
|
3060
3510
|
async waitToHide(locator, sec) {
|
|
3061
3511
|
const waitTimeout = sec ? sec * 1000 : this.options.waitForTimeout
|
|
3062
3512
|
locator = new Locator(locator, 'css')
|
|
3513
|
+
|
|
3063
3514
|
const context = await this._getContext()
|
|
3064
3515
|
let waiter
|
|
3065
3516
|
let count = 0
|
|
@@ -3181,52 +3632,77 @@ class Playwright extends Helper {
|
|
|
3181
3632
|
if (context) {
|
|
3182
3633
|
const locator = new Locator(context, 'css')
|
|
3183
3634
|
try {
|
|
3635
|
+
if (locator.isCustom()) {
|
|
3636
|
+
// For custom locators, find the elements first then check for text within them
|
|
3637
|
+
const elements = await findCustomElements.call(this, contextObject, locator)
|
|
3638
|
+
if (elements.length === 0) {
|
|
3639
|
+
throw new Error(`Context element not found: ${locator.toString()}`)
|
|
3640
|
+
}
|
|
3641
|
+
return elements[0].locator(`text=${text}`).first().waitFor({ timeout: waitTimeout, state: 'visible' })
|
|
3642
|
+
}
|
|
3643
|
+
|
|
3184
3644
|
if (!locator.isXPath()) {
|
|
3185
3645
|
return contextObject
|
|
3186
|
-
.locator(`${locator.
|
|
3646
|
+
.locator(`${locator.simplify()} >> text=${text}`)
|
|
3187
3647
|
.first()
|
|
3188
3648
|
.waitFor({ timeout: waitTimeout, state: 'visible' })
|
|
3649
|
+
.catch(e => {
|
|
3650
|
+
throw new Error(errorMessage)
|
|
3651
|
+
})
|
|
3189
3652
|
}
|
|
3190
3653
|
|
|
3191
3654
|
if (locator.isXPath()) {
|
|
3192
|
-
return contextObject
|
|
3193
|
-
(
|
|
3194
|
-
|
|
3195
|
-
|
|
3196
|
-
|
|
3197
|
-
|
|
3198
|
-
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
|
|
3655
|
+
return contextObject
|
|
3656
|
+
.waitForFunction(
|
|
3657
|
+
([locator, text, $XPath]) => {
|
|
3658
|
+
eval($XPath)
|
|
3659
|
+
const el = $XPath(null, locator)
|
|
3660
|
+
if (!el.length) return false
|
|
3661
|
+
return el[0].innerText.indexOf(text) > -1
|
|
3662
|
+
},
|
|
3663
|
+
[locator.value, text, $XPath.toString()],
|
|
3664
|
+
{ timeout: waitTimeout },
|
|
3665
|
+
)
|
|
3666
|
+
.catch(e => {
|
|
3667
|
+
throw new Error(errorMessage)
|
|
3668
|
+
})
|
|
3202
3669
|
}
|
|
3203
3670
|
} catch (e) {
|
|
3204
3671
|
throw new Error(`${errorMessage}\n${e.message}`)
|
|
3205
3672
|
}
|
|
3206
3673
|
}
|
|
3207
3674
|
|
|
3675
|
+
// Based on original implementation but fixed to check title text and remove problematic promiseRetry
|
|
3676
|
+
// Original used timeoutGap for waitForFunction to give it slightly more time than the locator
|
|
3208
3677
|
const timeoutGap = waitTimeout + 1000
|
|
3209
3678
|
|
|
3210
|
-
// We add basic timeout to make sure we don't wait forever
|
|
3211
|
-
// We apply 2 strategies here: wait for text as innert text on page (wide strategy) - older
|
|
3212
|
-
// or we use native Playwright matcher to wait for text in element (narrow strategy) - newer
|
|
3213
|
-
// If a user waits for text on a page they are mostly expect it to be there, so wide strategy can be helpful even PW strategy is available
|
|
3214
3679
|
return Promise.race([
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
|
|
3218
|
-
this.page.waitForFunction(
|
|
3219
|
-
|
|
3220
|
-
|
|
3221
|
-
|
|
3222
|
-
|
|
3223
|
-
|
|
3224
|
-
|
|
3225
|
-
if (
|
|
3680
|
+
// Strategy 1: waitForFunction that checks both body AND title text
|
|
3681
|
+
// Use this.page instead of contextObject because FrameLocator doesn't have waitForFunction
|
|
3682
|
+
// Original only checked document.body.innerText, missing title text like "TestEd"
|
|
3683
|
+
this.page.waitForFunction(
|
|
3684
|
+
function (text) {
|
|
3685
|
+
// Check body text (original behavior)
|
|
3686
|
+
if (document.body && document.body.innerText && document.body.innerText.indexOf(text) > -1) {
|
|
3687
|
+
return true
|
|
3688
|
+
}
|
|
3689
|
+
// Check document title (fixes the TestEd in title issue)
|
|
3690
|
+
if (document.title && document.title.indexOf(text) > -1) {
|
|
3691
|
+
return true
|
|
3692
|
+
}
|
|
3693
|
+
return false
|
|
3226
3694
|
},
|
|
3227
|
-
|
|
3695
|
+
text,
|
|
3696
|
+
{ timeout: timeoutGap },
|
|
3228
3697
|
),
|
|
3229
|
-
|
|
3698
|
+
// Strategy 2: Native Playwright text locator (replaces problematic promiseRetry)
|
|
3699
|
+
contextObject
|
|
3700
|
+
.locator(`:has-text(${JSON.stringify(text)})`)
|
|
3701
|
+
.first()
|
|
3702
|
+
.waitFor({ timeout: waitTimeout }),
|
|
3703
|
+
]).catch(err => {
|
|
3704
|
+
throw new Error(errorMessage)
|
|
3705
|
+
})
|
|
3230
3706
|
}
|
|
3231
3707
|
|
|
3232
3708
|
/**
|
|
@@ -3356,7 +3832,7 @@ class Playwright extends Helper {
|
|
|
3356
3832
|
/**
|
|
3357
3833
|
* Waits for navigation to finish. By default, it takes configured `waitForNavigation` option.
|
|
3358
3834
|
*
|
|
3359
|
-
* See [Playwright's reference](https://playwright.dev/docs/api/class-page
|
|
3835
|
+
* See [Playwright's reference](https://playwright.dev/docs/api/class-page#page-wait-for-navigation)
|
|
3360
3836
|
*
|
|
3361
3837
|
* @param {*} options
|
|
3362
3838
|
*/
|
|
@@ -3832,6 +4308,195 @@ class Playwright extends Helper {
|
|
|
3832
4308
|
|
|
3833
4309
|
export default Playwright
|
|
3834
4310
|
|
|
4311
|
+
function buildCustomLocatorString(locator) {
|
|
4312
|
+
// Note: this.debug not available in standalone function, using console.log
|
|
4313
|
+
console.log(`Building custom locator string: ${locator.type}=${locator.value}`)
|
|
4314
|
+
return `${locator.type}=${locator.value}`
|
|
4315
|
+
}
|
|
4316
|
+
|
|
4317
|
+
function buildLocatorString(locator) {
|
|
4318
|
+
if (locator.isCustom()) {
|
|
4319
|
+
return buildCustomLocatorString(locator)
|
|
4320
|
+
}
|
|
4321
|
+
if (locator.isXPath()) {
|
|
4322
|
+
return `xpath=${locator.value}`
|
|
4323
|
+
}
|
|
4324
|
+
return locator.simplify()
|
|
4325
|
+
}
|
|
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
|
+
|
|
4348
|
+
async function findElements(matcher, locator) {
|
|
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
|
|
4361
|
+
|
|
4362
|
+
locator = new Locator(locator, 'css')
|
|
4363
|
+
|
|
4364
|
+
// Handle custom locators directly instead of relying on Playwright selector engines
|
|
4365
|
+
if (locator.isCustom()) {
|
|
4366
|
+
return findCustomElements.call(this, matcher, locator)
|
|
4367
|
+
}
|
|
4368
|
+
|
|
4369
|
+
// Check if we have a custom context locator and need to search within it
|
|
4370
|
+
if (this.contextLocator) {
|
|
4371
|
+
const contextLocatorObj = new Locator(this.contextLocator, 'css')
|
|
4372
|
+
if (contextLocatorObj.isCustom()) {
|
|
4373
|
+
// Find the context elements first
|
|
4374
|
+
const contextElements = await findCustomElements.call(this, matcher, contextLocatorObj)
|
|
4375
|
+
if (contextElements.length === 0) {
|
|
4376
|
+
return []
|
|
4377
|
+
}
|
|
4378
|
+
|
|
4379
|
+
// Search within the first context element
|
|
4380
|
+
const locatorString = buildLocatorString(locator)
|
|
4381
|
+
return contextElements[0].locator(locatorString).all()
|
|
4382
|
+
}
|
|
4383
|
+
}
|
|
4384
|
+
|
|
4385
|
+
const locatorString = buildLocatorString(locator)
|
|
4386
|
+
|
|
4387
|
+
return matcher.locator(locatorString).all()
|
|
4388
|
+
}
|
|
4389
|
+
|
|
4390
|
+
async function findCustomElements(matcher, locator) {
|
|
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
|
+
}
|
|
4401
|
+
|
|
4402
|
+
if (!strategyFunction) {
|
|
4403
|
+
throw new Error(`Custom locator strategy "${locator.type}" is not defined. Please define "customLocatorStrategies" in your configuration.`)
|
|
4404
|
+
}
|
|
4405
|
+
|
|
4406
|
+
// Execute the custom locator function in the browser context using page.evaluate
|
|
4407
|
+
const page = matcher.constructor.name === 'Page' ? matcher : await matcher.page()
|
|
4408
|
+
|
|
4409
|
+
const elements = await page.evaluate(
|
|
4410
|
+
({ strategyCode, selector }) => {
|
|
4411
|
+
const strategy = new Function('return ' + strategyCode)()
|
|
4412
|
+
const result = strategy(selector, document)
|
|
4413
|
+
|
|
4414
|
+
// Convert NodeList or single element to array
|
|
4415
|
+
if (result && result.nodeType) {
|
|
4416
|
+
return [result]
|
|
4417
|
+
} else if (result && result.length !== undefined) {
|
|
4418
|
+
return Array.from(result)
|
|
4419
|
+
} else if (Array.isArray(result)) {
|
|
4420
|
+
return result
|
|
4421
|
+
}
|
|
4422
|
+
|
|
4423
|
+
return []
|
|
4424
|
+
},
|
|
4425
|
+
{
|
|
4426
|
+
strategyCode: strategyFunction.toString(),
|
|
4427
|
+
selector: locator.value,
|
|
4428
|
+
},
|
|
4429
|
+
)
|
|
4430
|
+
|
|
4431
|
+
// Convert the found elements back to Playwright locators
|
|
4432
|
+
if (elements.length === 0) {
|
|
4433
|
+
return []
|
|
4434
|
+
}
|
|
4435
|
+
|
|
4436
|
+
// Create CSS selectors for the found elements and return as locators
|
|
4437
|
+
const locators = []
|
|
4438
|
+
const timestamp = Date.now()
|
|
4439
|
+
|
|
4440
|
+
for (let i = 0; i < elements.length; i++) {
|
|
4441
|
+
// Use a unique attribute approach to target specific elements
|
|
4442
|
+
const uniqueAttr = `data-codecept-custom-${timestamp}-${i}`
|
|
4443
|
+
|
|
4444
|
+
await page.evaluate(
|
|
4445
|
+
({ index, uniqueAttr, strategyCode, selector }) => {
|
|
4446
|
+
// Re-execute the strategy to find elements and mark the specific one
|
|
4447
|
+
const strategy = new Function('return ' + strategyCode)()
|
|
4448
|
+
const result = strategy(selector, document)
|
|
4449
|
+
|
|
4450
|
+
let elementsArray = []
|
|
4451
|
+
if (result && result.nodeType) {
|
|
4452
|
+
elementsArray = [result]
|
|
4453
|
+
} else if (result && result.length !== undefined) {
|
|
4454
|
+
elementsArray = Array.from(result)
|
|
4455
|
+
} else if (Array.isArray(result)) {
|
|
4456
|
+
elementsArray = result
|
|
4457
|
+
}
|
|
4458
|
+
|
|
4459
|
+
if (elementsArray[index]) {
|
|
4460
|
+
elementsArray[index].setAttribute(uniqueAttr, 'true')
|
|
4461
|
+
}
|
|
4462
|
+
},
|
|
4463
|
+
{
|
|
4464
|
+
index: i,
|
|
4465
|
+
uniqueAttr,
|
|
4466
|
+
strategyCode: strategyFunction.toString(),
|
|
4467
|
+
selector: locator.value,
|
|
4468
|
+
},
|
|
4469
|
+
)
|
|
4470
|
+
|
|
4471
|
+
locators.push(page.locator(`[${uniqueAttr}="true"]`))
|
|
4472
|
+
}
|
|
4473
|
+
|
|
4474
|
+
return locators
|
|
4475
|
+
}
|
|
4476
|
+
|
|
4477
|
+
async function findElement(matcher, locator) {
|
|
4478
|
+
if (locator.react) return findReact(matcher, locator)
|
|
4479
|
+
if (locator.vue) return findVue(matcher, locator)
|
|
4480
|
+
if (locator.pw) return findByPlaywrightLocator.call(this, matcher, locator)
|
|
4481
|
+
|
|
4482
|
+
locator = new Locator(locator, 'css')
|
|
4483
|
+
|
|
4484
|
+
return matcher.locator(buildLocatorString(locator)).first()
|
|
4485
|
+
}
|
|
4486
|
+
|
|
4487
|
+
async function getVisibleElements(elements) {
|
|
4488
|
+
const visibleElements = []
|
|
4489
|
+
for (const element of elements) {
|
|
4490
|
+
if (await element.isVisible()) {
|
|
4491
|
+
visibleElements.push(element)
|
|
4492
|
+
}
|
|
4493
|
+
}
|
|
4494
|
+
if (visibleElements.length === 0) {
|
|
4495
|
+
return elements
|
|
4496
|
+
}
|
|
4497
|
+
return visibleElements
|
|
4498
|
+
}
|
|
4499
|
+
|
|
3835
4500
|
async function proceedClick(locator, context = null, options = {}) {
|
|
3836
4501
|
let matcher = await this._getContext()
|
|
3837
4502
|
if (context) {
|
|
@@ -3941,6 +4606,10 @@ async function findCheckable(locator, context) {
|
|
|
3941
4606
|
contextEl = contextEl[0]
|
|
3942
4607
|
}
|
|
3943
4608
|
|
|
4609
|
+
// Handle role locators with text/exact options
|
|
4610
|
+
const roleElements = await handleRoleLocator(contextEl, locator)
|
|
4611
|
+
if (roleElements) return roleElements
|
|
4612
|
+
|
|
3944
4613
|
const matchedLocator = new Locator(locator)
|
|
3945
4614
|
if (!matchedLocator.isFuzzy()) {
|
|
3946
4615
|
return findElements.call(this, contextEl, matchedLocator)
|
|
@@ -3967,6 +4636,13 @@ async function proceedIsChecked(assertType, option) {
|
|
|
3967
4636
|
}
|
|
3968
4637
|
|
|
3969
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
|
+
|
|
3970
4646
|
const matchedLocator = new Locator(locator)
|
|
3971
4647
|
if (!matchedLocator.isFuzzy()) {
|
|
3972
4648
|
return this._locate(matchedLocator)
|
|
@@ -4145,9 +4821,7 @@ async function targetCreatedHandler(page) {
|
|
|
4145
4821
|
if (this.options.windowSize && this.options.windowSize.indexOf('x') > 0 && this._getType() === 'Browser') {
|
|
4146
4822
|
try {
|
|
4147
4823
|
await page.setViewportSize(parseWindowSize(this.options.windowSize))
|
|
4148
|
-
} catch (err) {
|
|
4149
|
-
this.debug('Target can be already closed, ignoring...')
|
|
4150
|
-
}
|
|
4824
|
+
} catch (err) {}
|
|
4151
4825
|
}
|
|
4152
4826
|
}
|
|
4153
4827
|
|
|
@@ -4289,6 +4963,11 @@ async function refreshContextSession() {
|
|
|
4289
4963
|
}
|
|
4290
4964
|
|
|
4291
4965
|
try {
|
|
4966
|
+
if (!this.page || !this.browserContext) {
|
|
4967
|
+
this.debugSection('Session', 'Skipping storage cleanup - no active page/context')
|
|
4968
|
+
return
|
|
4969
|
+
}
|
|
4970
|
+
|
|
4292
4971
|
const currentUrl = await this.grabCurrentUrl()
|
|
4293
4972
|
|
|
4294
4973
|
if (currentUrl.startsWith('http')) {
|
|
@@ -4322,9 +5001,18 @@ function saveVideoForPage(page, name) {
|
|
|
4322
5001
|
async function saveTraceForContext(context, name) {
|
|
4323
5002
|
if (!context) return
|
|
4324
5003
|
if (!context.tracing) return
|
|
4325
|
-
|
|
4326
|
-
|
|
4327
|
-
|
|
5004
|
+
try {
|
|
5005
|
+
const fileName = `${`${global.output_dir}${pathSeparator}trace${pathSeparator}${uuidv4()}_${clearString(name)}`.slice(0, 245)}.zip`
|
|
5006
|
+
await context.tracing.stop({ path: fileName })
|
|
5007
|
+
return fileName
|
|
5008
|
+
} catch (err) {
|
|
5009
|
+
// Handle the case where tracing was not started or context is invalid
|
|
5010
|
+
if (err.message && err.message.includes('Must start tracing before stopping')) {
|
|
5011
|
+
// Tracing was never started on this context, silently skip
|
|
5012
|
+
return null
|
|
5013
|
+
}
|
|
5014
|
+
throw err
|
|
5015
|
+
}
|
|
4328
5016
|
}
|
|
4329
5017
|
|
|
4330
5018
|
async function highlightActiveElement(element) {
|