codeceptjs 4.0.0-rc.7 → 4.0.0-rc.9
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/docs/webapi/moveCursorTo.mustache +5 -1
- package/lib/element/WebElement.js +52 -0
- package/lib/helper/Playwright.js +62 -65
- package/lib/helper/Puppeteer.js +63 -31
- package/lib/helper/WebDriver.js +61 -31
- package/lib/helper/errors/MultipleElementsFound.js +27 -110
- package/lib/helper/extras/elementSelection.js +51 -0
- package/lib/helper/scripts/dropFile.js +11 -0
- package/lib/html.js +14 -1
- package/lib/mocha/cli.js +10 -0
- package/lib/step/config.js +8 -2
- package/package.json +1 -1
- package/typings/index.d.ts +19 -0
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
Moves cursor to element matched by locator.
|
|
2
2
|
Extra shift can be set with offsetX and offsetY options.
|
|
3
3
|
|
|
4
|
+
An optional `context` (as a second parameter) can be specified to narrow the search to an element within a parent.
|
|
5
|
+
When the second argument is a non-number (string or locator object), it is treated as context.
|
|
6
|
+
|
|
4
7
|
```js
|
|
5
8
|
I.moveCursorTo('.tooltip');
|
|
6
9
|
I.moveCursorTo('#submit', 5,5);
|
|
10
|
+
I.moveCursorTo('#submit', '.container');
|
|
7
11
|
```
|
|
8
12
|
|
|
9
13
|
@param {CodeceptJS.LocatorOrString} locator located by CSS|XPath|strict locator.
|
|
10
|
-
@param {number} [offsetX=0] (optional, `0` by default) X-axis offset.
|
|
14
|
+
@param {number|CodeceptJS.LocatorOrString} [offsetX=0] (optional, `0` by default) X-axis offset or context locator.
|
|
11
15
|
@param {number} [offsetY=0] (optional, `0` by default) Y-axis offset.
|
|
12
16
|
@returns {void} automatically synchronized promise through #recorder
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import assert from 'assert'
|
|
2
|
+
import { simplifyHtmlElement } from '../html.js'
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Unified WebElement class that wraps native element instances from different helpers
|
|
@@ -306,6 +307,57 @@ class WebElement {
|
|
|
306
307
|
* @returns {string} Normalized CSS selector
|
|
307
308
|
* @private
|
|
308
309
|
*/
|
|
310
|
+
async toAbsoluteXPath() {
|
|
311
|
+
const xpathFn = (el) => {
|
|
312
|
+
const parts = []
|
|
313
|
+
let current = el
|
|
314
|
+
while (current && current.nodeType === Node.ELEMENT_NODE) {
|
|
315
|
+
let index = 0
|
|
316
|
+
let sibling = current.previousSibling
|
|
317
|
+
while (sibling) {
|
|
318
|
+
if (sibling.nodeType === Node.ELEMENT_NODE && sibling.tagName === current.tagName) {
|
|
319
|
+
index++
|
|
320
|
+
}
|
|
321
|
+
sibling = sibling.previousSibling
|
|
322
|
+
}
|
|
323
|
+
const tagName = current.tagName.toLowerCase()
|
|
324
|
+
const pathIndex = index > 0 ? `[${index + 1}]` : ''
|
|
325
|
+
parts.unshift(`${tagName}${pathIndex}`)
|
|
326
|
+
current = current.parentElement
|
|
327
|
+
}
|
|
328
|
+
return '//' + parts.join('/')
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
switch (this.helperType) {
|
|
332
|
+
case 'playwright':
|
|
333
|
+
return this.element.evaluate(xpathFn)
|
|
334
|
+
case 'puppeteer':
|
|
335
|
+
return this.element.evaluate(xpathFn)
|
|
336
|
+
case 'webdriver':
|
|
337
|
+
return this.helper.browser.execute(xpathFn, this.element)
|
|
338
|
+
default:
|
|
339
|
+
throw new Error(`Unsupported helper type: ${this.helperType}`)
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async toOuterHTML() {
|
|
344
|
+
switch (this.helperType) {
|
|
345
|
+
case 'playwright':
|
|
346
|
+
return this.element.evaluate(el => el.outerHTML)
|
|
347
|
+
case 'puppeteer':
|
|
348
|
+
return this.element.evaluate(el => el.outerHTML)
|
|
349
|
+
case 'webdriver':
|
|
350
|
+
return this.helper.browser.execute(el => el.outerHTML, this.element)
|
|
351
|
+
default:
|
|
352
|
+
throw new Error(`Unsupported helper type: ${this.helperType}`)
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async toSimplifiedHTML(maxLength = 300) {
|
|
357
|
+
const outerHTML = await this.toOuterHTML()
|
|
358
|
+
return simplifyHtmlElement(outerHTML, maxLength)
|
|
359
|
+
}
|
|
360
|
+
|
|
309
361
|
_normalizeLocator(locator) {
|
|
310
362
|
if (typeof locator === 'string') {
|
|
311
363
|
return locator
|
package/lib/helper/Playwright.js
CHANGED
|
@@ -36,7 +36,9 @@ import RemoteBrowserConnectionRefused from './errors/RemoteBrowserConnectionRefu
|
|
|
36
36
|
import Popup from './extras/Popup.js'
|
|
37
37
|
import Console from './extras/Console.js'
|
|
38
38
|
import { findReact, findVue, findByPlaywrightLocator } from './extras/PlaywrightReactVueLocator.js'
|
|
39
|
+
import { dropFile } from './scripts/dropFile.js'
|
|
39
40
|
import WebElement from '../element/WebElement.js'
|
|
41
|
+
import { selectElement } from './extras/elementSelection.js'
|
|
40
42
|
|
|
41
43
|
let playwright
|
|
42
44
|
let perfTiming
|
|
@@ -1494,8 +1496,23 @@ class Playwright extends Helper {
|
|
|
1494
1496
|
*
|
|
1495
1497
|
*/
|
|
1496
1498
|
async moveCursorTo(locator, offsetX = 0, offsetY = 0) {
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
+
let context = null
|
|
1500
|
+
if (typeof offsetX !== 'number') {
|
|
1501
|
+
context = offsetX
|
|
1502
|
+
offsetX = 0
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
let el
|
|
1506
|
+
if (context) {
|
|
1507
|
+
const contextEls = await this._locate(context)
|
|
1508
|
+
assertElementExists(contextEls, context, 'Context element')
|
|
1509
|
+
el = await findElements.call(this, contextEls[0], locator)
|
|
1510
|
+
assertElementExists(el, locator)
|
|
1511
|
+
el = el[0]
|
|
1512
|
+
} else {
|
|
1513
|
+
el = await this._locateElement(locator)
|
|
1514
|
+
assertElementExists(el, locator)
|
|
1515
|
+
}
|
|
1499
1516
|
|
|
1500
1517
|
// Use manual mouse.move instead of .hover() so the offset can be added to the coordinates
|
|
1501
1518
|
const { x, y } = await clickablePoint(el)
|
|
@@ -1763,8 +1780,7 @@ class Playwright extends Helper {
|
|
|
1763
1780
|
if (elements.length === 0) {
|
|
1764
1781
|
throw new ElementNotFound(locator, 'Element', 'was not found')
|
|
1765
1782
|
}
|
|
1766
|
-
|
|
1767
|
-
return elements[0]
|
|
1783
|
+
return selectElement(elements, locator, this)
|
|
1768
1784
|
}
|
|
1769
1785
|
|
|
1770
1786
|
/**
|
|
@@ -1779,8 +1795,7 @@ class Playwright extends Helper {
|
|
|
1779
1795
|
const context = providedContext || (await this._getContext())
|
|
1780
1796
|
const els = await findCheckable.call(this, locator, context)
|
|
1781
1797
|
assertElementExists(els[0], locator, 'Checkbox or radio')
|
|
1782
|
-
|
|
1783
|
-
return els[0]
|
|
1798
|
+
return selectElement(els, locator, this)
|
|
1784
1799
|
}
|
|
1785
1800
|
|
|
1786
1801
|
/**
|
|
@@ -2266,8 +2281,7 @@ class Playwright extends Helper {
|
|
|
2266
2281
|
async fillField(field, value, context = null) {
|
|
2267
2282
|
const els = await findFields.call(this, field, context)
|
|
2268
2283
|
assertElementExists(els, field, 'Field')
|
|
2269
|
-
|
|
2270
|
-
const el = els[0]
|
|
2284
|
+
const el = selectElement(els, field, this)
|
|
2271
2285
|
|
|
2272
2286
|
await el.clear()
|
|
2273
2287
|
if (store.debugMode) this.debugSection('Focused', await elToString(el, 1))
|
|
@@ -2285,9 +2299,8 @@ class Playwright extends Helper {
|
|
|
2285
2299
|
async clearField(locator, context = null) {
|
|
2286
2300
|
const els = await findFields.call(this, locator, context)
|
|
2287
2301
|
assertElementExists(els, locator, 'Field to clear')
|
|
2288
|
-
if (this.options.strict) assertOnlyOneElement(els, locator)
|
|
2289
2302
|
|
|
2290
|
-
const el = els
|
|
2303
|
+
const el = selectElement(els, locator, this)
|
|
2291
2304
|
|
|
2292
2305
|
await highlightActiveElement.call(this, el)
|
|
2293
2306
|
|
|
@@ -2302,10 +2315,10 @@ class Playwright extends Helper {
|
|
|
2302
2315
|
async appendField(field, value, context = null) {
|
|
2303
2316
|
const els = await findFields.call(this, field, context)
|
|
2304
2317
|
assertElementExists(els, field, 'Field')
|
|
2305
|
-
|
|
2306
|
-
await highlightActiveElement.call(this,
|
|
2307
|
-
await
|
|
2308
|
-
await
|
|
2318
|
+
const el = selectElement(els, field, this)
|
|
2319
|
+
await highlightActiveElement.call(this, el)
|
|
2320
|
+
await el.press('End')
|
|
2321
|
+
await el.type(value.toString(), { delay: this.options.pressKeyDelay })
|
|
2309
2322
|
return this._waitForAction()
|
|
2310
2323
|
}
|
|
2311
2324
|
|
|
@@ -2337,30 +2350,24 @@ class Playwright extends Helper {
|
|
|
2337
2350
|
}
|
|
2338
2351
|
const els = await findFields.call(this, locator, context)
|
|
2339
2352
|
if (els.length) {
|
|
2340
|
-
const
|
|
2341
|
-
const
|
|
2353
|
+
const el = selectElement(els, locator, this)
|
|
2354
|
+
const tag = await el.evaluate(el => el.tagName)
|
|
2355
|
+
const type = await el.evaluate(el => el.type)
|
|
2342
2356
|
if (tag === 'INPUT' && type === 'file') {
|
|
2343
|
-
await
|
|
2357
|
+
await el.setInputFiles(file)
|
|
2344
2358
|
return this._waitForAction()
|
|
2345
2359
|
}
|
|
2346
2360
|
}
|
|
2347
2361
|
|
|
2348
2362
|
const targetEls = els.length ? els : await this._locate(locator)
|
|
2349
2363
|
assertElementExists(targetEls, locator, 'Element')
|
|
2350
|
-
const
|
|
2351
|
-
const
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
const fileObj = new File([bytes], fileName, { type: mimeType })
|
|
2358
|
-
const dataTransfer = new DataTransfer()
|
|
2359
|
-
dataTransfer.items.add(fileObj)
|
|
2360
|
-
el.dispatchEvent(new DragEvent('dragenter', { dataTransfer, bubbles: true }))
|
|
2361
|
-
el.dispatchEvent(new DragEvent('dragover', { dataTransfer, bubbles: true }))
|
|
2362
|
-
el.dispatchEvent(new DragEvent('drop', { dataTransfer, bubbles: true }))
|
|
2363
|
-
}, { base64Content, fileName, mimeType })
|
|
2364
|
+
const el = selectElement(targetEls, locator, this)
|
|
2365
|
+
const fileData = {
|
|
2366
|
+
base64Content: base64EncodeFile(file),
|
|
2367
|
+
fileName: path.basename(file),
|
|
2368
|
+
mimeType: getMimeType(path.basename(file)),
|
|
2369
|
+
}
|
|
2370
|
+
await el.evaluate(dropFile, fileData)
|
|
2364
2371
|
return this._waitForAction()
|
|
2365
2372
|
}
|
|
2366
2373
|
|
|
@@ -2383,23 +2390,23 @@ class Playwright extends Helper {
|
|
|
2383
2390
|
this.debugSection('SelectOption', `Strict: ${JSON.stringify(select)}`)
|
|
2384
2391
|
const els = contextEl ? await findElements.call(this, contextEl, matchedLocator) : await this._locate(matchedLocator)
|
|
2385
2392
|
assertElementExists(els, select, 'Selectable element')
|
|
2386
|
-
return proceedSelect.call(this, pageContext, els
|
|
2393
|
+
return proceedSelect.call(this, pageContext, selectElement(els, select, this), option)
|
|
2387
2394
|
}
|
|
2388
2395
|
|
|
2389
2396
|
// Fuzzy: try combobox
|
|
2390
2397
|
this.debugSection('SelectOption', `Fuzzy: "${matchedLocator.value}"`)
|
|
2391
2398
|
const comboboxSearchCtx = contextEl || pageContext
|
|
2392
2399
|
let els = await findByRole(comboboxSearchCtx, { role: 'combobox', name: matchedLocator.value })
|
|
2393
|
-
if (els?.length) return proceedSelect.call(this, pageContext, els
|
|
2400
|
+
if (els?.length) return proceedSelect.call(this, pageContext, selectElement(els, select, this), option)
|
|
2394
2401
|
|
|
2395
2402
|
// Fuzzy: try listbox
|
|
2396
2403
|
els = await findByRole(comboboxSearchCtx, { role: 'listbox', name: matchedLocator.value })
|
|
2397
|
-
if (els?.length) return proceedSelect.call(this, pageContext, els
|
|
2404
|
+
if (els?.length) return proceedSelect.call(this, pageContext, selectElement(els, select, this), option)
|
|
2398
2405
|
|
|
2399
2406
|
// Fuzzy: try native select
|
|
2400
2407
|
els = await findFields.call(this, select, context)
|
|
2401
2408
|
assertElementExists(els, select, 'Selectable element')
|
|
2402
|
-
return proceedSelect.call(this, pageContext, els
|
|
2409
|
+
return proceedSelect.call(this, pageContext, selectElement(els, select, this), option)
|
|
2403
2410
|
}
|
|
2404
2411
|
|
|
2405
2412
|
/**
|
|
@@ -4274,16 +4281,21 @@ async function proceedClick(locator, context = null, options = {}) {
|
|
|
4274
4281
|
assertElementExists(els, locator, 'Clickable element')
|
|
4275
4282
|
}
|
|
4276
4283
|
|
|
4277
|
-
|
|
4278
|
-
|
|
4284
|
+
const elementIndex = store.currentStep?.opts?.elementIndex
|
|
4285
|
+
let element
|
|
4286
|
+
if (elementIndex != null) {
|
|
4287
|
+
element = selectElement(els, locator, this)
|
|
4288
|
+
} else {
|
|
4289
|
+
if (this.options.strict) assertOnlyOneElement(els, locator, this)
|
|
4290
|
+
element = els.length > 1 ? (await getVisibleElements(els))[0] : els[0]
|
|
4291
|
+
}
|
|
4292
|
+
|
|
4293
|
+
await highlightActiveElement.call(this, element)
|
|
4294
|
+
if (store.debugMode) this.debugSection('Clicked', await elToString(element, 1))
|
|
4279
4295
|
|
|
4280
|
-
/*
|
|
4281
|
-
using the force true options itself but instead dispatching a click
|
|
4282
|
-
*/
|
|
4283
4296
|
if (options.force) {
|
|
4284
|
-
await
|
|
4297
|
+
await element.dispatchEvent('click')
|
|
4285
4298
|
} else {
|
|
4286
|
-
const element = els.length > 1 ? (await getVisibleElements(els))[0] : els[0]
|
|
4287
4299
|
await element.click(options)
|
|
4288
4300
|
}
|
|
4289
4301
|
const promises = []
|
|
@@ -4300,7 +4312,6 @@ async function findClickable(matcher, locator) {
|
|
|
4300
4312
|
|
|
4301
4313
|
if (!matchedLocator.isFuzzy()) {
|
|
4302
4314
|
const els = await findElements.call(this, matcher, matchedLocator)
|
|
4303
|
-
if (this.options.strict) assertOnlyOneElement(els, locator)
|
|
4304
4315
|
return els
|
|
4305
4316
|
}
|
|
4306
4317
|
|
|
@@ -4309,42 +4320,27 @@ async function findClickable(matcher, locator) {
|
|
|
4309
4320
|
|
|
4310
4321
|
try {
|
|
4311
4322
|
els = await matcher.getByRole('button', { name: matchedLocator.value }).all()
|
|
4312
|
-
if (els.length)
|
|
4313
|
-
if (this.options.strict) assertOnlyOneElement(els, locator)
|
|
4314
|
-
return els
|
|
4315
|
-
}
|
|
4323
|
+
if (els.length) return els
|
|
4316
4324
|
} catch (err) {
|
|
4317
4325
|
// getByRole not supported or failed
|
|
4318
4326
|
}
|
|
4319
4327
|
|
|
4320
4328
|
try {
|
|
4321
4329
|
els = await matcher.getByRole('link', { name: matchedLocator.value }).all()
|
|
4322
|
-
if (els.length)
|
|
4323
|
-
if (this.options.strict) assertOnlyOneElement(els, locator)
|
|
4324
|
-
return els
|
|
4325
|
-
}
|
|
4330
|
+
if (els.length) return els
|
|
4326
4331
|
} catch (err) {
|
|
4327
4332
|
// getByRole not supported or failed
|
|
4328
4333
|
}
|
|
4329
4334
|
|
|
4330
4335
|
els = await findElements.call(this, matcher, Locator.clickable.narrow(literal))
|
|
4331
|
-
if (els.length)
|
|
4332
|
-
if (this.options.strict) assertOnlyOneElement(els, locator)
|
|
4333
|
-
return els
|
|
4334
|
-
}
|
|
4336
|
+
if (els.length) return els
|
|
4335
4337
|
|
|
4336
4338
|
els = await findElements.call(this, matcher, Locator.clickable.wide(literal))
|
|
4337
|
-
if (els.length)
|
|
4338
|
-
if (this.options.strict) assertOnlyOneElement(els, locator)
|
|
4339
|
-
return els
|
|
4340
|
-
}
|
|
4339
|
+
if (els.length) return els
|
|
4341
4340
|
|
|
4342
4341
|
try {
|
|
4343
4342
|
els = await findElements.call(this, matcher, Locator.clickable.self(literal))
|
|
4344
|
-
if (els.length)
|
|
4345
|
-
if (this.options.strict) assertOnlyOneElement(els, locator)
|
|
4346
|
-
return els
|
|
4347
|
-
}
|
|
4343
|
+
if (els.length) return els
|
|
4348
4344
|
} catch (err) {
|
|
4349
4345
|
// Do nothing
|
|
4350
4346
|
}
|
|
@@ -4619,9 +4615,10 @@ function assertElementExists(res, locator, prefix, suffix) {
|
|
|
4619
4615
|
}
|
|
4620
4616
|
}
|
|
4621
4617
|
|
|
4622
|
-
function assertOnlyOneElement(elements, locator) {
|
|
4618
|
+
function assertOnlyOneElement(elements, locator, helper) {
|
|
4623
4619
|
if (elements.length > 1) {
|
|
4624
|
-
|
|
4620
|
+
const webElements = elements.map(el => new WebElement(el, helper))
|
|
4621
|
+
throw new MultipleElementsFound(locator, webElements)
|
|
4625
4622
|
}
|
|
4626
4623
|
}
|
|
4627
4624
|
|
package/lib/helper/Puppeteer.js
CHANGED
|
@@ -33,14 +33,17 @@ import {
|
|
|
33
33
|
} from '../utils.js'
|
|
34
34
|
import { isColorProperty, convertColorToRGBA } from '../colorUtils.js'
|
|
35
35
|
import ElementNotFound from './errors/ElementNotFound.js'
|
|
36
|
+
import MultipleElementsFound from './errors/MultipleElementsFound.js'
|
|
36
37
|
import RemoteBrowserConnectionRefused from './errors/RemoteBrowserConnectionRefused.js'
|
|
37
38
|
import Popup from './extras/Popup.js'
|
|
38
39
|
import Console from './extras/Console.js'
|
|
39
40
|
import { highlightElement } from './scripts/highlightElement.js'
|
|
40
41
|
import { blurElement } from './scripts/blurElement.js'
|
|
42
|
+
import { dropFile } from './scripts/dropFile.js'
|
|
41
43
|
import { dontSeeElementError, seeElementError, dontSeeElementInDOMError, seeElementInDOMError } from './errors/ElementAssertion.js'
|
|
42
44
|
import { dontSeeTraffic, seeTraffic, grabRecordedNetworkTraffics, stopRecordingTraffic, flushNetworkTraffics } from './network/actions.js'
|
|
43
45
|
import WebElement from '../element/WebElement.js'
|
|
46
|
+
import { selectElement } from './extras/elementSelection.js'
|
|
44
47
|
|
|
45
48
|
let puppeteer
|
|
46
49
|
|
|
@@ -270,6 +273,7 @@ class Puppeteer extends Helper {
|
|
|
270
273
|
show: false,
|
|
271
274
|
defaultPopupAction: 'accept',
|
|
272
275
|
highlightElement: false,
|
|
276
|
+
strict: false,
|
|
273
277
|
}
|
|
274
278
|
|
|
275
279
|
return Object.assign(defaults, config)
|
|
@@ -818,9 +822,26 @@ class Puppeteer extends Helper {
|
|
|
818
822
|
* {{ react }}
|
|
819
823
|
*/
|
|
820
824
|
async moveCursorTo(locator, offsetX = 0, offsetY = 0) {
|
|
821
|
-
|
|
822
|
-
if (
|
|
823
|
-
|
|
825
|
+
let context = null
|
|
826
|
+
if (typeof offsetX !== 'number') {
|
|
827
|
+
context = offsetX
|
|
828
|
+
offsetX = 0
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
let el
|
|
832
|
+
if (context) {
|
|
833
|
+
const contextEls = await findElements.call(this, this.page, context)
|
|
834
|
+
assertElementExists(contextEls, context, 'Context element')
|
|
835
|
+
const els = await findElements.call(this, contextEls[0], locator)
|
|
836
|
+
if (!els || els.length === 0) {
|
|
837
|
+
throw new ElementNotFound(locator, 'Element to move cursor to')
|
|
838
|
+
}
|
|
839
|
+
el = els[0]
|
|
840
|
+
} else {
|
|
841
|
+
el = await this._locateElement(locator)
|
|
842
|
+
if (!el) {
|
|
843
|
+
throw new ElementNotFound(locator, 'Element to move cursor to')
|
|
844
|
+
}
|
|
824
845
|
}
|
|
825
846
|
|
|
826
847
|
// Use manual mouse.move instead of .hover() so the offset can be added to the coordinates
|
|
@@ -988,6 +1009,14 @@ class Puppeteer extends Helper {
|
|
|
988
1009
|
*/
|
|
989
1010
|
async _locateElement(locator) {
|
|
990
1011
|
const context = await this.context
|
|
1012
|
+
const elementIndex = store.currentStep?.opts?.elementIndex
|
|
1013
|
+
if (this.options.strict || elementIndex) {
|
|
1014
|
+
const elements = await findElements.call(this, context, locator)
|
|
1015
|
+
if (elements.length === 0) {
|
|
1016
|
+
throw new ElementNotFound(locator, 'Element', 'was not found')
|
|
1017
|
+
}
|
|
1018
|
+
return selectElement(elements, locator, this)
|
|
1019
|
+
}
|
|
991
1020
|
return findElement.call(this, context, locator)
|
|
992
1021
|
}
|
|
993
1022
|
|
|
@@ -1005,7 +1034,7 @@ class Puppeteer extends Helper {
|
|
|
1005
1034
|
if (!els || els.length === 0) {
|
|
1006
1035
|
throw new ElementNotFound(locator, 'Checkbox or radio')
|
|
1007
1036
|
}
|
|
1008
|
-
return els
|
|
1037
|
+
return selectElement(els, locator, this)
|
|
1009
1038
|
}
|
|
1010
1039
|
|
|
1011
1040
|
/**
|
|
@@ -1564,7 +1593,7 @@ class Puppeteer extends Helper {
|
|
|
1564
1593
|
async fillField(field, value, context = null) {
|
|
1565
1594
|
const els = await findVisibleFields.call(this, field, context)
|
|
1566
1595
|
assertElementExists(els, field, 'Field')
|
|
1567
|
-
const el = els
|
|
1596
|
+
const el = selectElement(els, field, this)
|
|
1568
1597
|
const tag = await el.getProperty('tagName').then(el => el.jsonValue())
|
|
1569
1598
|
const editable = await el.getProperty('contenteditable').then(el => el.jsonValue())
|
|
1570
1599
|
if (tag === 'INPUT' || tag === 'TEXTAREA') {
|
|
@@ -1594,9 +1623,10 @@ class Puppeteer extends Helper {
|
|
|
1594
1623
|
async appendField(field, value, context = null) {
|
|
1595
1624
|
const els = await findVisibleFields.call(this, field, context)
|
|
1596
1625
|
assertElementExists(els, field, 'Field')
|
|
1597
|
-
|
|
1598
|
-
await
|
|
1599
|
-
await
|
|
1626
|
+
const el = selectElement(els, field, this)
|
|
1627
|
+
highlightActiveElement.call(this, el, await this._getContext())
|
|
1628
|
+
await el.press('End')
|
|
1629
|
+
await el.type(value.toString(), { delay: this.options.pressKeyDelay })
|
|
1600
1630
|
return this._waitForAction()
|
|
1601
1631
|
}
|
|
1602
1632
|
|
|
@@ -1629,30 +1659,24 @@ class Puppeteer extends Helper {
|
|
|
1629
1659
|
}
|
|
1630
1660
|
const els = await findFields.call(this, locator, context)
|
|
1631
1661
|
if (els.length) {
|
|
1632
|
-
const
|
|
1633
|
-
const
|
|
1662
|
+
const el = selectElement(els, locator, this)
|
|
1663
|
+
const tag = await el.evaluate(el => el.tagName)
|
|
1664
|
+
const type = await el.evaluate(el => el.type)
|
|
1634
1665
|
if (tag === 'INPUT' && type === 'file') {
|
|
1635
|
-
await
|
|
1666
|
+
await el.uploadFile(file)
|
|
1636
1667
|
return this._waitForAction()
|
|
1637
1668
|
}
|
|
1638
1669
|
}
|
|
1639
1670
|
|
|
1640
1671
|
const targetEls = els.length ? els : await this._locate(locator)
|
|
1641
1672
|
assertElementExists(targetEls, locator, 'Element')
|
|
1642
|
-
const
|
|
1643
|
-
const
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
const fileObj = new File([bytes], fileName, { type: mimeType })
|
|
1650
|
-
const dataTransfer = new DataTransfer()
|
|
1651
|
-
dataTransfer.items.add(fileObj)
|
|
1652
|
-
el.dispatchEvent(new DragEvent('dragenter', { dataTransfer, bubbles: true }))
|
|
1653
|
-
el.dispatchEvent(new DragEvent('dragover', { dataTransfer, bubbles: true }))
|
|
1654
|
-
el.dispatchEvent(new DragEvent('drop', { dataTransfer, bubbles: true }))
|
|
1655
|
-
}, { base64Content, fileName, mimeType })
|
|
1673
|
+
const el = selectElement(targetEls, locator, this)
|
|
1674
|
+
const fileData = {
|
|
1675
|
+
base64Content: base64EncodeFile(file),
|
|
1676
|
+
fileName: path.basename(file),
|
|
1677
|
+
mimeType: getMimeType(path.basename(file)),
|
|
1678
|
+
}
|
|
1679
|
+
await el.evaluate(dropFile, fileData)
|
|
1656
1680
|
return this._waitForAction()
|
|
1657
1681
|
}
|
|
1658
1682
|
|
|
@@ -1675,23 +1699,23 @@ class Puppeteer extends Helper {
|
|
|
1675
1699
|
this.debugSection('SelectOption', `Strict: ${JSON.stringify(select)}`)
|
|
1676
1700
|
const els = contextEl ? await findElements.call(this, contextEl, select) : await this._locate(select)
|
|
1677
1701
|
assertElementExists(els, select, 'Selectable element')
|
|
1678
|
-
return proceedSelect.call(this, pageContext, els
|
|
1702
|
+
return proceedSelect.call(this, pageContext, selectElement(els, select, this), option)
|
|
1679
1703
|
}
|
|
1680
1704
|
|
|
1681
1705
|
// Fuzzy: try combobox
|
|
1682
1706
|
this.debugSection('SelectOption', `Fuzzy: "${matchedLocator.value}"`)
|
|
1683
1707
|
const comboboxSearchCtx = contextEl || pageContext
|
|
1684
1708
|
let els = await findByRole(comboboxSearchCtx, { role: 'combobox', name: matchedLocator.value })
|
|
1685
|
-
if (els?.length) return proceedSelect.call(this, pageContext, els
|
|
1709
|
+
if (els?.length) return proceedSelect.call(this, pageContext, selectElement(els, select, this), option)
|
|
1686
1710
|
|
|
1687
1711
|
// Fuzzy: try listbox
|
|
1688
1712
|
els = await findByRole(comboboxSearchCtx, { role: 'listbox', name: matchedLocator.value })
|
|
1689
|
-
if (els?.length) return proceedSelect.call(this, pageContext, els
|
|
1713
|
+
if (els?.length) return proceedSelect.call(this, pageContext, selectElement(els, select, this), option)
|
|
1690
1714
|
|
|
1691
1715
|
// Fuzzy: try native select
|
|
1692
1716
|
const visibleEls = await findVisibleFields.call(this, select, context)
|
|
1693
1717
|
assertElementExists(visibleEls, select, 'Selectable field')
|
|
1694
|
-
return proceedSelect.call(this, pageContext, visibleEls
|
|
1718
|
+
return proceedSelect.call(this, pageContext, selectElement(visibleEls, select, this), option)
|
|
1695
1719
|
}
|
|
1696
1720
|
|
|
1697
1721
|
/**
|
|
@@ -3098,10 +3122,11 @@ async function proceedClick(locator, context = null, options = {}) {
|
|
|
3098
3122
|
} else {
|
|
3099
3123
|
assertElementExists(els, locator, 'Clickable element')
|
|
3100
3124
|
}
|
|
3125
|
+
const el = selectElement(els, locator, this)
|
|
3101
3126
|
|
|
3102
|
-
highlightActiveElement.call(this,
|
|
3127
|
+
highlightActiveElement.call(this, el, await this._getContext())
|
|
3103
3128
|
|
|
3104
|
-
await
|
|
3129
|
+
await el.click(options)
|
|
3105
3130
|
const promises = []
|
|
3106
3131
|
if (options.waitForNavigation) {
|
|
3107
3132
|
promises.push(this.waitForNavigation())
|
|
@@ -3425,6 +3450,13 @@ function assertElementExists(res, locator, prefix, suffix) {
|
|
|
3425
3450
|
}
|
|
3426
3451
|
}
|
|
3427
3452
|
|
|
3453
|
+
function assertOnlyOneElement(elements, locator, helper) {
|
|
3454
|
+
if (elements.length > 1) {
|
|
3455
|
+
const webElements = elements.map(el => new WebElement(el, helper))
|
|
3456
|
+
throw new MultipleElementsFound(locator, webElements)
|
|
3457
|
+
}
|
|
3458
|
+
}
|
|
3459
|
+
|
|
3428
3460
|
function $XPath(element, selector) {
|
|
3429
3461
|
const found = document.evaluate(selector, element || document.body, null, 5, null)
|
|
3430
3462
|
const res = []
|
package/lib/helper/WebDriver.js
CHANGED
|
@@ -30,14 +30,17 @@ import {
|
|
|
30
30
|
} from '../utils.js'
|
|
31
31
|
import { isColorProperty, convertColorToRGBA } from '../colorUtils.js'
|
|
32
32
|
import ElementNotFound from './errors/ElementNotFound.js'
|
|
33
|
+
import MultipleElementsFound from './errors/MultipleElementsFound.js'
|
|
33
34
|
import ConnectionRefused from './errors/ConnectionRefused.js'
|
|
34
35
|
import Locator from '../locator.js'
|
|
35
36
|
import { highlightElement } from './scripts/highlightElement.js'
|
|
36
37
|
import { focusElement } from './scripts/focusElement.js'
|
|
37
38
|
import { blurElement } from './scripts/blurElement.js'
|
|
38
39
|
import { dontSeeElementError, seeElementError, seeElementInDOMError, dontSeeElementInDOMError } from './errors/ElementAssertion.js'
|
|
40
|
+
import { dropFile } from './scripts/dropFile.js'
|
|
39
41
|
import { dontSeeTraffic, seeTraffic, grabRecordedNetworkTraffics, stopRecordingTraffic, flushNetworkTraffics } from './network/actions.js'
|
|
40
42
|
import WebElement from '../element/WebElement.js'
|
|
43
|
+
import { selectElement } from './extras/elementSelection.js'
|
|
41
44
|
|
|
42
45
|
const SHADOW = 'shadow'
|
|
43
46
|
const webRoot = 'body'
|
|
@@ -503,6 +506,7 @@ class WebDriver extends Helper {
|
|
|
503
506
|
keepBrowserState: false,
|
|
504
507
|
deprecationWarnings: false,
|
|
505
508
|
highlightElement: false,
|
|
509
|
+
strict: false,
|
|
506
510
|
}
|
|
507
511
|
|
|
508
512
|
// override defaults with config
|
|
@@ -1090,7 +1094,7 @@ class WebDriver extends Helper {
|
|
|
1090
1094
|
} else {
|
|
1091
1095
|
assertElementExists(res, locator, 'Clickable element')
|
|
1092
1096
|
}
|
|
1093
|
-
const elem =
|
|
1097
|
+
const elem = selectElement(res, locator, this)
|
|
1094
1098
|
highlightActiveElement.call(this, elem)
|
|
1095
1099
|
return this.browser[clickMethod](getElementId(elem))
|
|
1096
1100
|
}
|
|
@@ -1109,7 +1113,7 @@ class WebDriver extends Helper {
|
|
|
1109
1113
|
} else {
|
|
1110
1114
|
assertElementExists(res, locator, 'Clickable element')
|
|
1111
1115
|
}
|
|
1112
|
-
const elem =
|
|
1116
|
+
const elem = selectElement(res, locator, this)
|
|
1113
1117
|
highlightActiveElement.call(this, elem)
|
|
1114
1118
|
|
|
1115
1119
|
return this.executeScript(el => {
|
|
@@ -1137,7 +1141,7 @@ class WebDriver extends Helper {
|
|
|
1137
1141
|
assertElementExists(res, locator, 'Clickable element')
|
|
1138
1142
|
}
|
|
1139
1143
|
|
|
1140
|
-
const elem =
|
|
1144
|
+
const elem = selectElement(res, locator, this)
|
|
1141
1145
|
highlightActiveElement.call(this, elem)
|
|
1142
1146
|
return elem.doubleClick()
|
|
1143
1147
|
}
|
|
@@ -1157,7 +1161,7 @@ class WebDriver extends Helper {
|
|
|
1157
1161
|
assertElementExists(res, locator, 'Clickable element')
|
|
1158
1162
|
}
|
|
1159
1163
|
|
|
1160
|
-
const el =
|
|
1164
|
+
const el = selectElement(res, locator, this)
|
|
1161
1165
|
|
|
1162
1166
|
await el.moveTo()
|
|
1163
1167
|
|
|
@@ -1272,7 +1276,7 @@ class WebDriver extends Helper {
|
|
|
1272
1276
|
async fillField(field, value, context = null) {
|
|
1273
1277
|
const res = await findFields.call(this, field, context)
|
|
1274
1278
|
assertElementExists(res, field, 'Field')
|
|
1275
|
-
const elem =
|
|
1279
|
+
const elem = selectElement(res, field, this)
|
|
1276
1280
|
highlightActiveElement.call(this, elem)
|
|
1277
1281
|
try {
|
|
1278
1282
|
await elem.clearValue()
|
|
@@ -1295,7 +1299,7 @@ class WebDriver extends Helper {
|
|
|
1295
1299
|
async appendField(field, value, context = null) {
|
|
1296
1300
|
const res = await findFields.call(this, field, context)
|
|
1297
1301
|
assertElementExists(res, field, 'Field')
|
|
1298
|
-
const elem =
|
|
1302
|
+
const elem = selectElement(res, field, this)
|
|
1299
1303
|
highlightActiveElement.call(this, elem)
|
|
1300
1304
|
return elem.addValue(value.toString())
|
|
1301
1305
|
}
|
|
@@ -1307,7 +1311,7 @@ class WebDriver extends Helper {
|
|
|
1307
1311
|
async clearField(field, context = null) {
|
|
1308
1312
|
const res = await findFields.call(this, field, context)
|
|
1309
1313
|
assertElementExists(res, field, 'Field')
|
|
1310
|
-
const elem =
|
|
1314
|
+
const elem = selectElement(res, field, this)
|
|
1311
1315
|
highlightActiveElement.call(this, elem)
|
|
1312
1316
|
return elem.clearValue(getElementId(elem))
|
|
1313
1317
|
}
|
|
@@ -1324,22 +1328,22 @@ class WebDriver extends Helper {
|
|
|
1324
1328
|
this.debugSection('SelectOption', `Strict: ${JSON.stringify(select)}`)
|
|
1325
1329
|
const els = await locateFn(select)
|
|
1326
1330
|
assertElementExists(els, select, 'Selectable element')
|
|
1327
|
-
return proceedSelectOption.call(this,
|
|
1331
|
+
return proceedSelectOption.call(this, selectElement(els, select, this), option)
|
|
1328
1332
|
}
|
|
1329
1333
|
|
|
1330
1334
|
// Fuzzy: try combobox
|
|
1331
1335
|
this.debugSection('SelectOption', `Fuzzy: "${matchedLocator.value}"`)
|
|
1332
1336
|
let els = await this._locateByRole({ role: 'combobox', text: matchedLocator.value })
|
|
1333
|
-
if (els?.length) return proceedSelectOption.call(this,
|
|
1337
|
+
if (els?.length) return proceedSelectOption.call(this, selectElement(els, select, this), option)
|
|
1334
1338
|
|
|
1335
1339
|
// Fuzzy: try listbox
|
|
1336
1340
|
els = await this._locateByRole({ role: 'listbox', text: matchedLocator.value })
|
|
1337
|
-
if (els?.length) return proceedSelectOption.call(this,
|
|
1341
|
+
if (els?.length) return proceedSelectOption.call(this, selectElement(els, select, this), option)
|
|
1338
1342
|
|
|
1339
1343
|
// Fuzzy: try native select
|
|
1340
1344
|
const res = await findFields.call(this, select, context)
|
|
1341
1345
|
assertElementExists(res, select, 'Selectable field')
|
|
1342
|
-
return proceedSelectOption.call(this,
|
|
1346
|
+
return proceedSelectOption.call(this, selectElement(res, select, this), option)
|
|
1343
1347
|
}
|
|
1344
1348
|
|
|
1345
1349
|
/**
|
|
@@ -1357,7 +1361,7 @@ class WebDriver extends Helper {
|
|
|
1357
1361
|
this.debug(`Uploading ${file}`)
|
|
1358
1362
|
|
|
1359
1363
|
if (res.length) {
|
|
1360
|
-
const el =
|
|
1364
|
+
const el = selectElement(res, locator, this)
|
|
1361
1365
|
const tag = await this.browser.execute(function (elem) { return elem.tagName }, el)
|
|
1362
1366
|
const type = await this.browser.execute(function (elem) { return elem.type }, el)
|
|
1363
1367
|
if (tag === 'INPUT' && type === 'file') {
|
|
@@ -1375,21 +1379,13 @@ class WebDriver extends Helper {
|
|
|
1375
1379
|
|
|
1376
1380
|
const targetRes = res.length ? res : await this._locate(locator)
|
|
1377
1381
|
assertElementExists(targetRes, locator, 'Element')
|
|
1378
|
-
const targetEl =
|
|
1379
|
-
const
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
for (var i = 0; i < binaryStr.length; i++) bytes[i] = binaryStr.charCodeAt(i)
|
|
1386
|
-
var fileObj = new File([bytes], data.fileName, { type: data.mimeType })
|
|
1387
|
-
var dataTransfer = new DataTransfer()
|
|
1388
|
-
dataTransfer.items.add(fileObj)
|
|
1389
|
-
el.dispatchEvent(new DragEvent('dragenter', { dataTransfer: dataTransfer, bubbles: true }))
|
|
1390
|
-
el.dispatchEvent(new DragEvent('dragover', { dataTransfer: dataTransfer, bubbles: true }))
|
|
1391
|
-
el.dispatchEvent(new DragEvent('drop', { dataTransfer: dataTransfer, bubbles: true }))
|
|
1392
|
-
}, targetEl, { base64Content, fileName, mimeType })
|
|
1382
|
+
const targetEl = selectElement(targetRes, locator, this)
|
|
1383
|
+
const fileData = {
|
|
1384
|
+
base64Content: base64EncodeFile(file),
|
|
1385
|
+
fileName: path.basename(file),
|
|
1386
|
+
mimeType: getMimeType(path.basename(file)),
|
|
1387
|
+
}
|
|
1388
|
+
return this.browser.execute(dropFile, targetEl, fileData)
|
|
1393
1389
|
}
|
|
1394
1390
|
|
|
1395
1391
|
/**
|
|
@@ -1403,7 +1399,7 @@ class WebDriver extends Helper {
|
|
|
1403
1399
|
const res = await findCheckable.call(this, field, locateFn)
|
|
1404
1400
|
|
|
1405
1401
|
assertElementExists(res, field, 'Checkable')
|
|
1406
|
-
const elem =
|
|
1402
|
+
const elem = selectElement(res, field, this)
|
|
1407
1403
|
const elementId = getElementId(elem)
|
|
1408
1404
|
highlightActiveElement.call(this, elem)
|
|
1409
1405
|
|
|
@@ -1424,7 +1420,7 @@ class WebDriver extends Helper {
|
|
|
1424
1420
|
const res = await findCheckable.call(this, field, locateFn)
|
|
1425
1421
|
|
|
1426
1422
|
assertElementExists(res, field, 'Checkable')
|
|
1427
|
-
const elem =
|
|
1423
|
+
const elem = selectElement(res, field, this)
|
|
1428
1424
|
const elementId = getElementId(elem)
|
|
1429
1425
|
highlightActiveElement.call(this, elem)
|
|
1430
1426
|
|
|
@@ -1974,8 +1970,22 @@ class WebDriver extends Helper {
|
|
|
1974
1970
|
* {{> moveCursorTo }}
|
|
1975
1971
|
*/
|
|
1976
1972
|
async moveCursorTo(locator, xOffset, yOffset) {
|
|
1977
|
-
|
|
1978
|
-
|
|
1973
|
+
let context = null
|
|
1974
|
+
if (typeof xOffset !== 'number' && xOffset !== undefined) {
|
|
1975
|
+
context = xOffset
|
|
1976
|
+
xOffset = undefined
|
|
1977
|
+
}
|
|
1978
|
+
|
|
1979
|
+
let res
|
|
1980
|
+
if (context) {
|
|
1981
|
+
const contextRes = await this._locate(withStrictLocator(context), true)
|
|
1982
|
+
assertElementExists(contextRes, context, 'Context element')
|
|
1983
|
+
res = await contextRes[0].$$(withStrictLocator(locator))
|
|
1984
|
+
assertElementExists(res, locator)
|
|
1985
|
+
} else {
|
|
1986
|
+
res = await this._locate(withStrictLocator(locator), true)
|
|
1987
|
+
assertElementExists(res, locator)
|
|
1988
|
+
}
|
|
1979
1989
|
const elem = usingFirstElement(res)
|
|
1980
1990
|
try {
|
|
1981
1991
|
await elem.moveTo({ xOffset, yOffset })
|
|
@@ -3285,10 +3295,30 @@ function assertElementExists(res, locator, prefix, suffix) {
|
|
|
3285
3295
|
}
|
|
3286
3296
|
|
|
3287
3297
|
function usingFirstElement(els) {
|
|
3298
|
+
const rawIndex = store.currentStep?.opts?.elementIndex
|
|
3299
|
+
if (rawIndex != null && els.length > 1) {
|
|
3300
|
+
let elementIndex = rawIndex
|
|
3301
|
+
if (elementIndex === 'first') elementIndex = 1
|
|
3302
|
+
if (elementIndex === 'last') elementIndex = -1
|
|
3303
|
+
if (Number.isInteger(elementIndex) && elementIndex !== 0) {
|
|
3304
|
+
const idx = elementIndex > 0 ? elementIndex - 1 : els.length + elementIndex
|
|
3305
|
+
if (idx >= 0 && idx < els.length) {
|
|
3306
|
+
debug(`[Elements] Using element #${rawIndex} out of ${els.length}`)
|
|
3307
|
+
return els[idx]
|
|
3308
|
+
}
|
|
3309
|
+
}
|
|
3310
|
+
}
|
|
3288
3311
|
if (els.length > 1) debug(`[Elements] Using first element out of ${els.length}`)
|
|
3289
3312
|
return els[0]
|
|
3290
3313
|
}
|
|
3291
3314
|
|
|
3315
|
+
function assertOnlyOneElement(elements, locator, helper) {
|
|
3316
|
+
if (elements.length > 1) {
|
|
3317
|
+
const webElements = Array.from(elements).map(el => new WebElement(el, helper))
|
|
3318
|
+
throw new MultipleElementsFound(locator, webElements)
|
|
3319
|
+
}
|
|
3320
|
+
}
|
|
3321
|
+
|
|
3292
3322
|
function getElementId(el) {
|
|
3293
3323
|
// W3C WebDriver web element identifier
|
|
3294
3324
|
// https://w3c.github.io/webdriver/#dfn-web-element-identifier
|
|
@@ -1,40 +1,45 @@
|
|
|
1
1
|
import Locator from '../../locator.js'
|
|
2
2
|
|
|
3
|
-
/**
|
|
4
|
-
* Error thrown when strict mode is enabled and multiple elements are found
|
|
5
|
-
* for a single-element locator operation (click, fillField, etc.)
|
|
6
|
-
*/
|
|
7
3
|
class MultipleElementsFound extends Error {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
super(`Multiple elements (${elements.length}) found for "${locator}". Call fetchDetails() for full information.`)
|
|
4
|
+
constructor(locator, webElements) {
|
|
5
|
+
const locatorStr = (typeof locator === 'object' && !(locator instanceof Locator))
|
|
6
|
+
? new Locator(locator).toString()
|
|
7
|
+
: String(locator)
|
|
8
|
+
super(`Multiple elements (${webElements.length}) found for "${locatorStr}" in strict mode. Call fetchDetails() for full information.`)
|
|
14
9
|
this.name = 'MultipleElementsFound'
|
|
15
10
|
this.locator = locator
|
|
16
|
-
this.
|
|
17
|
-
this.count =
|
|
11
|
+
this.webElements = webElements
|
|
12
|
+
this.count = webElements.length
|
|
18
13
|
this._detailsFetched = false
|
|
19
14
|
}
|
|
20
15
|
|
|
21
|
-
/**
|
|
22
|
-
* Fetch detailed information about the found elements asynchronously
|
|
23
|
-
* This updates the error message with XPath and element previews
|
|
24
|
-
*/
|
|
25
16
|
async fetchDetails() {
|
|
26
17
|
if (this._detailsFetched) return
|
|
27
18
|
|
|
28
19
|
try {
|
|
29
|
-
|
|
30
|
-
|
|
20
|
+
const items = []
|
|
21
|
+
const maxToShow = Math.min(this.count, 10)
|
|
22
|
+
|
|
23
|
+
for (let i = 0; i < maxToShow; i++) {
|
|
24
|
+
const webEl = this.webElements[i]
|
|
25
|
+
try {
|
|
26
|
+
const xpath = await webEl.toAbsoluteXPath()
|
|
27
|
+
const html = await webEl.toSimplifiedHTML()
|
|
28
|
+
items.push(` ${i + 1}. > ${xpath}\n ${html}`)
|
|
29
|
+
} catch (err) {
|
|
30
|
+
items.push(` ${i + 1}. [Unable to get element info: ${err.message}]`)
|
|
31
|
+
}
|
|
31
32
|
}
|
|
32
33
|
|
|
33
|
-
|
|
34
|
-
|
|
34
|
+
if (this.count > 10) {
|
|
35
|
+
items.push(` ... and ${this.count - 10} more`)
|
|
36
|
+
}
|
|
35
37
|
|
|
36
|
-
this.
|
|
37
|
-
|
|
38
|
+
const locatorStr = (typeof this.locator === 'object' && !(this.locator instanceof Locator))
|
|
39
|
+
? new Locator(this.locator).toString()
|
|
40
|
+
: String(this.locator)
|
|
41
|
+
this.message = `Multiple elements (${this.count}) found for "${locatorStr}" in strict mode.\n` +
|
|
42
|
+
items.join('\n') +
|
|
38
43
|
`\nUse a more specific locator or use grabWebElements() to handle multiple elements.`
|
|
39
44
|
} catch (err) {
|
|
40
45
|
this.message = `Multiple elements (${this.count}) found. Failed to fetch details: ${err.message}`
|
|
@@ -42,94 +47,6 @@ class MultipleElementsFound extends Error {
|
|
|
42
47
|
|
|
43
48
|
this._detailsFetched = true
|
|
44
49
|
}
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Generate a formatted list of found elements with their XPath and preview
|
|
48
|
-
* @param {Array<HTMLElement>} elements
|
|
49
|
-
* @param {number} count
|
|
50
|
-
* @returns {Promise<string>}
|
|
51
|
-
*/
|
|
52
|
-
async _generateElementList(elements, count) {
|
|
53
|
-
const items = []
|
|
54
|
-
const maxToShow = Math.min(count, 10)
|
|
55
|
-
|
|
56
|
-
for (let i = 0; i < maxToShow; i++) {
|
|
57
|
-
const el = elements[i]
|
|
58
|
-
try {
|
|
59
|
-
const info = await this._getElementInfo(el)
|
|
60
|
-
items.push(` ${i + 1}. ${info.xpath} (${info.preview})`)
|
|
61
|
-
} catch (err) {
|
|
62
|
-
// Element might be detached or inaccessible
|
|
63
|
-
items.push(` ${i + 1}. [Unable to get element info: ${err.message}]`)
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
if (count > 10) {
|
|
68
|
-
items.push(` ... and ${count - 10} more`)
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
return items.join('\n')
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Get XPath and preview for an element by running JavaScript in browser context
|
|
76
|
-
* @param {HTMLElement} element
|
|
77
|
-
* @returns {Promise<{xpath: string, preview: string}>}
|
|
78
|
-
*/
|
|
79
|
-
async _getElementInfo(element) {
|
|
80
|
-
return element.evaluate((el) => {
|
|
81
|
-
// Generate a unique XPath for this element
|
|
82
|
-
const getUniqueXPath = (element) => {
|
|
83
|
-
if (element.id) {
|
|
84
|
-
return `//*[@id="${element.id}"]`
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
const parts = []
|
|
88
|
-
let current = element
|
|
89
|
-
|
|
90
|
-
while (current && current.nodeType === Node.ELEMENT_NODE) {
|
|
91
|
-
let index = 0
|
|
92
|
-
let sibling = current.previousSibling
|
|
93
|
-
|
|
94
|
-
while (sibling) {
|
|
95
|
-
if (sibling.nodeType === Node.ELEMENT_NODE && sibling.tagName === current.tagName) {
|
|
96
|
-
index++
|
|
97
|
-
}
|
|
98
|
-
sibling = sibling.previousSibling
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
const tagName = current.tagName.toLowerCase()
|
|
102
|
-
const pathIndex = index > 0 ? `[${index + 1}]` : ''
|
|
103
|
-
parts.unshift(`${tagName}${pathIndex}`)
|
|
104
|
-
|
|
105
|
-
current = current.parentElement
|
|
106
|
-
|
|
107
|
-
// Stop at body to keep XPath reasonable
|
|
108
|
-
if (current && current.tagName === 'BODY') {
|
|
109
|
-
parts.unshift('body')
|
|
110
|
-
break
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
return '/' + parts.join('/')
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
// Get a preview of the element (tag, classes, id)
|
|
118
|
-
const getPreview = (element) => {
|
|
119
|
-
const tag = element.tagName.toLowerCase()
|
|
120
|
-
const id = element.id ? `#${element.id}` : ''
|
|
121
|
-
const classes = element.className
|
|
122
|
-
? '.' + element.className.split(' ').filter(c => c).join('.')
|
|
123
|
-
: ''
|
|
124
|
-
return `${tag}${id}${classes || ''}`
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
return {
|
|
128
|
-
xpath: getUniqueXPath(el),
|
|
129
|
-
preview: getPreview(el),
|
|
130
|
-
}
|
|
131
|
-
})
|
|
132
|
-
}
|
|
133
50
|
}
|
|
134
51
|
|
|
135
52
|
export default MultipleElementsFound
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import store from '../../store.js'
|
|
2
|
+
import output from '../../output.js'
|
|
3
|
+
import WebElement from '../../element/WebElement.js'
|
|
4
|
+
import MultipleElementsFound from '../errors/MultipleElementsFound.js'
|
|
5
|
+
|
|
6
|
+
function resolveElementIndex(value) {
|
|
7
|
+
if (value === 'first') return 1
|
|
8
|
+
if (value === 'last') return -1
|
|
9
|
+
return value
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function selectElement(els, locator, helper) {
|
|
13
|
+
const rawIndex = store.currentStep?.opts?.elementIndex
|
|
14
|
+
const elementIndex = resolveElementIndex(rawIndex)
|
|
15
|
+
|
|
16
|
+
if (elementIndex != null) {
|
|
17
|
+
if (els.length === 1) return els[0]
|
|
18
|
+
|
|
19
|
+
if (!Number.isInteger(elementIndex) || elementIndex === 0) {
|
|
20
|
+
throw new Error(`elementIndex must be a non-zero integer or 'first'/'last', got: ${rawIndex}`)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let idx
|
|
24
|
+
if (elementIndex > 0) {
|
|
25
|
+
idx = elementIndex - 1
|
|
26
|
+
if (idx >= els.length) {
|
|
27
|
+
throw new Error(`elementIndex ${elementIndex} exceeds the number of elements found (${els.length}) for "${locator}"`)
|
|
28
|
+
}
|
|
29
|
+
} else {
|
|
30
|
+
idx = els.length + elementIndex
|
|
31
|
+
if (idx < 0) {
|
|
32
|
+
throw new Error(`elementIndex ${elementIndex} exceeds the number of elements found (${els.length}) for "${locator}"`)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
output.debug(`[Elements] Using element #${elementIndex} out of ${els.length}`)
|
|
37
|
+
return els[idx]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (helper.options.strict) {
|
|
41
|
+
if (els.length > 1) {
|
|
42
|
+
const webElements = els.map(el => new WebElement(el, helper))
|
|
43
|
+
throw new MultipleElementsFound(locator, webElements)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (els.length > 1) output.debug(`[Elements] Using first element out of ${els.length}`)
|
|
48
|
+
return els[0]
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export { selectElement }
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export const dropFile = (el, { base64Content, fileName, mimeType }) => {
|
|
2
|
+
const binaryStr = atob(base64Content)
|
|
3
|
+
const bytes = new Uint8Array(binaryStr.length)
|
|
4
|
+
for (let i = 0; i < binaryStr.length; i++) bytes[i] = binaryStr.charCodeAt(i)
|
|
5
|
+
const fileObj = new File([bytes], fileName, { type: mimeType })
|
|
6
|
+
const dataTransfer = new DataTransfer()
|
|
7
|
+
dataTransfer.items.add(fileObj)
|
|
8
|
+
el.dispatchEvent(new DragEvent('dragenter', { dataTransfer, bubbles: true }))
|
|
9
|
+
el.dispatchEvent(new DragEvent('dragover', { dataTransfer, bubbles: true }))
|
|
10
|
+
el.dispatchEvent(new DragEvent('drop', { dataTransfer, bubbles: true }))
|
|
11
|
+
}
|
package/lib/html.js
CHANGED
|
@@ -245,4 +245,17 @@ function splitByChunks(text, chunkSize) {
|
|
|
245
245
|
return chunks.map(chunk => chunk.trim())
|
|
246
246
|
}
|
|
247
247
|
|
|
248
|
-
|
|
248
|
+
function simplifyHtmlElement(html, maxLength = 300) {
|
|
249
|
+
try {
|
|
250
|
+
html = removeNonInteractiveElements(html)
|
|
251
|
+
html = html.replace(/<html>(?:<head>.*?<\/head>)?<body>(.*)<\/body><\/html>/s, '$1').trim()
|
|
252
|
+
} catch (e) {
|
|
253
|
+
// keep raw html if minification fails
|
|
254
|
+
}
|
|
255
|
+
if (html.length > maxLength) {
|
|
256
|
+
html = html.slice(0, maxLength) + '...'
|
|
257
|
+
}
|
|
258
|
+
return html
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export { scanForErrorMessages, removeNonInteractiveElements, splitByChunks, minifyHtml, simplifyHtmlElement }
|
package/lib/mocha/cli.js
CHANGED
|
@@ -202,6 +202,16 @@ class Cli extends Base {
|
|
|
202
202
|
|
|
203
203
|
// failures
|
|
204
204
|
if (stats.failures) {
|
|
205
|
+
for (const test of this.failures) {
|
|
206
|
+
if (test.err && typeof test.err.fetchDetails === 'function') {
|
|
207
|
+
try {
|
|
208
|
+
await test.err.fetchDetails()
|
|
209
|
+
} catch (e) {
|
|
210
|
+
// ignore fetch errors
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
205
215
|
// append step traces
|
|
206
216
|
this.failures = this.failures.map(test => {
|
|
207
217
|
// we will change the stack trace, so we need to clone the test
|
package/lib/step/config.js
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef {Object} StepOptions
|
|
3
|
+
* @property {number|'first'|'last'} [elementIndex] - Select a specific element when multiple match. 1-based positive index, negative from end, or 'first'/'last'.
|
|
4
|
+
* @property {boolean} [ignoreCase] - Perform case-insensitive text matching.
|
|
5
|
+
*/
|
|
6
|
+
|
|
1
7
|
/**
|
|
2
8
|
* StepConfig is a configuration object for a step.
|
|
3
9
|
* It is used to create a new step that is a combination of other steps.
|
|
4
10
|
*/
|
|
5
11
|
class StepConfig {
|
|
6
12
|
constructor(opts = {}) {
|
|
7
|
-
/** @member {{ opts:
|
|
13
|
+
/** @member {{ opts: StepOptions, timeout: number|undefined, retry: number|undefined }} */
|
|
8
14
|
this.config = {
|
|
9
15
|
opts,
|
|
10
16
|
timeout: undefined,
|
|
@@ -14,7 +20,7 @@ class StepConfig {
|
|
|
14
20
|
|
|
15
21
|
/**
|
|
16
22
|
* Set the options for the step.
|
|
17
|
-
* @param {
|
|
23
|
+
* @param {StepOptions} opts - The options for the step.
|
|
18
24
|
* @returns {StepConfig} - The step configuration object.
|
|
19
25
|
*/
|
|
20
26
|
opts(opts) {
|
package/package.json
CHANGED
package/typings/index.d.ts
CHANGED
|
@@ -745,3 +745,22 @@ declare module 'codeceptjs/effects' {
|
|
|
745
745
|
export const retryTo: RetryTo
|
|
746
746
|
export const hopeThat: HopeThat
|
|
747
747
|
}
|
|
748
|
+
|
|
749
|
+
declare module 'codeceptjs/steps' {
|
|
750
|
+
const step: {
|
|
751
|
+
opts(opts: CodeceptJS.StepOptions): CodeceptJS.StepConfig;
|
|
752
|
+
timeout(timeout: number): CodeceptJS.StepConfig;
|
|
753
|
+
retry(retry: number): CodeceptJS.StepConfig;
|
|
754
|
+
stepOpts(opts: CodeceptJS.StepOptions): CodeceptJS.StepConfig;
|
|
755
|
+
stepTimeout(timeout: number): CodeceptJS.StepConfig;
|
|
756
|
+
stepRetry(retry: number): CodeceptJS.StepConfig;
|
|
757
|
+
section(name: string): any;
|
|
758
|
+
endSection(): any;
|
|
759
|
+
Section(name: string): any;
|
|
760
|
+
EndSection(): any;
|
|
761
|
+
Given(): any;
|
|
762
|
+
When(): any;
|
|
763
|
+
Then(): any;
|
|
764
|
+
}
|
|
765
|
+
export default step
|
|
766
|
+
}
|