codeceptjs 4.0.0-rc.7 → 4.0.0-rc.8
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 +38 -29
- package/lib/helper/Puppeteer.js +48 -17
- package/lib/helper/WebDriver.js +39 -16
- package/lib/helper/errors/MultipleElementsFound.js +27 -110
- package/lib/helper/scripts/dropFile.js +11 -0
- package/lib/html.js +14 -1
- package/lib/mocha/cli.js +10 -0
- package/package.json +1 -1
|
@@ -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,6 +36,7 @@ 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'
|
|
40
41
|
|
|
41
42
|
let playwright
|
|
@@ -1494,8 +1495,23 @@ class Playwright extends Helper {
|
|
|
1494
1495
|
*
|
|
1495
1496
|
*/
|
|
1496
1497
|
async moveCursorTo(locator, offsetX = 0, offsetY = 0) {
|
|
1497
|
-
|
|
1498
|
-
|
|
1498
|
+
let context = null
|
|
1499
|
+
if (typeof offsetX !== 'number') {
|
|
1500
|
+
context = offsetX
|
|
1501
|
+
offsetX = 0
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
let el
|
|
1505
|
+
if (context) {
|
|
1506
|
+
const contextEls = await this._locate(context)
|
|
1507
|
+
assertElementExists(contextEls, context, 'Context element')
|
|
1508
|
+
el = await findElements.call(this, contextEls[0], locator)
|
|
1509
|
+
assertElementExists(el, locator)
|
|
1510
|
+
el = el[0]
|
|
1511
|
+
} else {
|
|
1512
|
+
el = await this._locateElement(locator)
|
|
1513
|
+
assertElementExists(el, locator)
|
|
1514
|
+
}
|
|
1499
1515
|
|
|
1500
1516
|
// Use manual mouse.move instead of .hover() so the offset can be added to the coordinates
|
|
1501
1517
|
const { x, y } = await clickablePoint(el)
|
|
@@ -1763,7 +1779,7 @@ class Playwright extends Helper {
|
|
|
1763
1779
|
if (elements.length === 0) {
|
|
1764
1780
|
throw new ElementNotFound(locator, 'Element', 'was not found')
|
|
1765
1781
|
}
|
|
1766
|
-
if (this.options.strict) assertOnlyOneElement(elements, locator)
|
|
1782
|
+
if (this.options.strict) assertOnlyOneElement(elements, locator, this)
|
|
1767
1783
|
return elements[0]
|
|
1768
1784
|
}
|
|
1769
1785
|
|
|
@@ -1779,7 +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
|
-
if (this.options.strict) assertOnlyOneElement(els, locator)
|
|
1798
|
+
if (this.options.strict) assertOnlyOneElement(els, locator, this)
|
|
1783
1799
|
return els[0]
|
|
1784
1800
|
}
|
|
1785
1801
|
|
|
@@ -2266,7 +2282,7 @@ class Playwright extends Helper {
|
|
|
2266
2282
|
async fillField(field, value, context = null) {
|
|
2267
2283
|
const els = await findFields.call(this, field, context)
|
|
2268
2284
|
assertElementExists(els, field, 'Field')
|
|
2269
|
-
if (this.options.strict) assertOnlyOneElement(els, field)
|
|
2285
|
+
if (this.options.strict) assertOnlyOneElement(els, field, this)
|
|
2270
2286
|
const el = els[0]
|
|
2271
2287
|
|
|
2272
2288
|
await el.clear()
|
|
@@ -2285,7 +2301,7 @@ class Playwright extends Helper {
|
|
|
2285
2301
|
async clearField(locator, context = null) {
|
|
2286
2302
|
const els = await findFields.call(this, locator, context)
|
|
2287
2303
|
assertElementExists(els, locator, 'Field to clear')
|
|
2288
|
-
if (this.options.strict) assertOnlyOneElement(els, locator)
|
|
2304
|
+
if (this.options.strict) assertOnlyOneElement(els, locator, this)
|
|
2289
2305
|
|
|
2290
2306
|
const el = els[0]
|
|
2291
2307
|
|
|
@@ -2302,7 +2318,7 @@ class Playwright extends Helper {
|
|
|
2302
2318
|
async appendField(field, value, context = null) {
|
|
2303
2319
|
const els = await findFields.call(this, field, context)
|
|
2304
2320
|
assertElementExists(els, field, 'Field')
|
|
2305
|
-
if (this.options.strict) assertOnlyOneElement(els, field)
|
|
2321
|
+
if (this.options.strict) assertOnlyOneElement(els, field, this)
|
|
2306
2322
|
await highlightActiveElement.call(this, els[0])
|
|
2307
2323
|
await els[0].press('End')
|
|
2308
2324
|
await els[0].type(value.toString(), { delay: this.options.pressKeyDelay })
|
|
@@ -2347,20 +2363,12 @@ class Playwright extends Helper {
|
|
|
2347
2363
|
|
|
2348
2364
|
const targetEls = els.length ? els : await this._locate(locator)
|
|
2349
2365
|
assertElementExists(targetEls, locator, 'Element')
|
|
2350
|
-
const
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
for (let i = 0; i < binaryStr.length; i++) bytes[i] = binaryStr.charCodeAt(i)
|
|
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 })
|
|
2366
|
+
const fileData = {
|
|
2367
|
+
base64Content: base64EncodeFile(file),
|
|
2368
|
+
fileName: path.basename(file),
|
|
2369
|
+
mimeType: getMimeType(path.basename(file)),
|
|
2370
|
+
}
|
|
2371
|
+
await targetEls[0].evaluate(dropFile, fileData)
|
|
2364
2372
|
return this._waitForAction()
|
|
2365
2373
|
}
|
|
2366
2374
|
|
|
@@ -4300,7 +4308,7 @@ async function findClickable(matcher, locator) {
|
|
|
4300
4308
|
|
|
4301
4309
|
if (!matchedLocator.isFuzzy()) {
|
|
4302
4310
|
const els = await findElements.call(this, matcher, matchedLocator)
|
|
4303
|
-
if (this.options.strict) assertOnlyOneElement(els, locator)
|
|
4311
|
+
if (this.options.strict) assertOnlyOneElement(els, locator, this)
|
|
4304
4312
|
return els
|
|
4305
4313
|
}
|
|
4306
4314
|
|
|
@@ -4310,7 +4318,7 @@ async function findClickable(matcher, locator) {
|
|
|
4310
4318
|
try {
|
|
4311
4319
|
els = await matcher.getByRole('button', { name: matchedLocator.value }).all()
|
|
4312
4320
|
if (els.length) {
|
|
4313
|
-
if (this.options.strict) assertOnlyOneElement(els, locator)
|
|
4321
|
+
if (this.options.strict) assertOnlyOneElement(els, locator, this)
|
|
4314
4322
|
return els
|
|
4315
4323
|
}
|
|
4316
4324
|
} catch (err) {
|
|
@@ -4320,7 +4328,7 @@ async function findClickable(matcher, locator) {
|
|
|
4320
4328
|
try {
|
|
4321
4329
|
els = await matcher.getByRole('link', { name: matchedLocator.value }).all()
|
|
4322
4330
|
if (els.length) {
|
|
4323
|
-
if (this.options.strict) assertOnlyOneElement(els, locator)
|
|
4331
|
+
if (this.options.strict) assertOnlyOneElement(els, locator, this)
|
|
4324
4332
|
return els
|
|
4325
4333
|
}
|
|
4326
4334
|
} catch (err) {
|
|
@@ -4329,20 +4337,20 @@ async function findClickable(matcher, locator) {
|
|
|
4329
4337
|
|
|
4330
4338
|
els = await findElements.call(this, matcher, Locator.clickable.narrow(literal))
|
|
4331
4339
|
if (els.length) {
|
|
4332
|
-
if (this.options.strict) assertOnlyOneElement(els, locator)
|
|
4340
|
+
if (this.options.strict) assertOnlyOneElement(els, locator, this)
|
|
4333
4341
|
return els
|
|
4334
4342
|
}
|
|
4335
4343
|
|
|
4336
4344
|
els = await findElements.call(this, matcher, Locator.clickable.wide(literal))
|
|
4337
4345
|
if (els.length) {
|
|
4338
|
-
if (this.options.strict) assertOnlyOneElement(els, locator)
|
|
4346
|
+
if (this.options.strict) assertOnlyOneElement(els, locator, this)
|
|
4339
4347
|
return els
|
|
4340
4348
|
}
|
|
4341
4349
|
|
|
4342
4350
|
try {
|
|
4343
4351
|
els = await findElements.call(this, matcher, Locator.clickable.self(literal))
|
|
4344
4352
|
if (els.length) {
|
|
4345
|
-
if (this.options.strict) assertOnlyOneElement(els, locator)
|
|
4353
|
+
if (this.options.strict) assertOnlyOneElement(els, locator, this)
|
|
4346
4354
|
return els
|
|
4347
4355
|
}
|
|
4348
4356
|
} catch (err) {
|
|
@@ -4619,9 +4627,10 @@ function assertElementExists(res, locator, prefix, suffix) {
|
|
|
4619
4627
|
}
|
|
4620
4628
|
}
|
|
4621
4629
|
|
|
4622
|
-
function assertOnlyOneElement(elements, locator) {
|
|
4630
|
+
function assertOnlyOneElement(elements, locator, helper) {
|
|
4623
4631
|
if (elements.length > 1) {
|
|
4624
|
-
|
|
4632
|
+
const webElements = elements.map(el => new WebElement(el, helper))
|
|
4633
|
+
throw new MultipleElementsFound(locator, webElements)
|
|
4625
4634
|
}
|
|
4626
4635
|
}
|
|
4627
4636
|
|
package/lib/helper/Puppeteer.js
CHANGED
|
@@ -33,11 +33,13 @@ 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'
|
|
@@ -270,6 +272,7 @@ class Puppeteer extends Helper {
|
|
|
270
272
|
show: false,
|
|
271
273
|
defaultPopupAction: 'accept',
|
|
272
274
|
highlightElement: false,
|
|
275
|
+
strict: false,
|
|
273
276
|
}
|
|
274
277
|
|
|
275
278
|
return Object.assign(defaults, config)
|
|
@@ -818,9 +821,26 @@ class Puppeteer extends Helper {
|
|
|
818
821
|
* {{ react }}
|
|
819
822
|
*/
|
|
820
823
|
async moveCursorTo(locator, offsetX = 0, offsetY = 0) {
|
|
821
|
-
|
|
822
|
-
if (
|
|
823
|
-
|
|
824
|
+
let context = null
|
|
825
|
+
if (typeof offsetX !== 'number') {
|
|
826
|
+
context = offsetX
|
|
827
|
+
offsetX = 0
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
let el
|
|
831
|
+
if (context) {
|
|
832
|
+
const contextEls = await findElements.call(this, this.page, context)
|
|
833
|
+
assertElementExists(contextEls, context, 'Context element')
|
|
834
|
+
const els = await findElements.call(this, contextEls[0], locator)
|
|
835
|
+
if (!els || els.length === 0) {
|
|
836
|
+
throw new ElementNotFound(locator, 'Element to move cursor to')
|
|
837
|
+
}
|
|
838
|
+
el = els[0]
|
|
839
|
+
} else {
|
|
840
|
+
el = await this._locateElement(locator)
|
|
841
|
+
if (!el) {
|
|
842
|
+
throw new ElementNotFound(locator, 'Element to move cursor to')
|
|
843
|
+
}
|
|
824
844
|
}
|
|
825
845
|
|
|
826
846
|
// Use manual mouse.move instead of .hover() so the offset can be added to the coordinates
|
|
@@ -988,6 +1008,14 @@ class Puppeteer extends Helper {
|
|
|
988
1008
|
*/
|
|
989
1009
|
async _locateElement(locator) {
|
|
990
1010
|
const context = await this.context
|
|
1011
|
+
if (this.options.strict) {
|
|
1012
|
+
const elements = await findElements.call(this, context, locator)
|
|
1013
|
+
if (elements.length === 0) {
|
|
1014
|
+
throw new ElementNotFound(locator, 'Element', 'was not found')
|
|
1015
|
+
}
|
|
1016
|
+
assertOnlyOneElement(elements, locator, this)
|
|
1017
|
+
return elements[0]
|
|
1018
|
+
}
|
|
991
1019
|
return findElement.call(this, context, locator)
|
|
992
1020
|
}
|
|
993
1021
|
|
|
@@ -1005,6 +1033,7 @@ class Puppeteer extends Helper {
|
|
|
1005
1033
|
if (!els || els.length === 0) {
|
|
1006
1034
|
throw new ElementNotFound(locator, 'Checkbox or radio')
|
|
1007
1035
|
}
|
|
1036
|
+
if (this.options.strict) assertOnlyOneElement(els, locator, this)
|
|
1008
1037
|
return els[0]
|
|
1009
1038
|
}
|
|
1010
1039
|
|
|
@@ -1564,6 +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')
|
|
1596
|
+
if (this.options.strict) assertOnlyOneElement(els, field, this)
|
|
1567
1597
|
const el = els[0]
|
|
1568
1598
|
const tag = await el.getProperty('tagName').then(el => el.jsonValue())
|
|
1569
1599
|
const editable = await el.getProperty('contenteditable').then(el => el.jsonValue())
|
|
@@ -1594,6 +1624,7 @@ class Puppeteer extends Helper {
|
|
|
1594
1624
|
async appendField(field, value, context = null) {
|
|
1595
1625
|
const els = await findVisibleFields.call(this, field, context)
|
|
1596
1626
|
assertElementExists(els, field, 'Field')
|
|
1627
|
+
if (this.options.strict) assertOnlyOneElement(els, field, this)
|
|
1597
1628
|
highlightActiveElement.call(this, els[0], await this._getContext())
|
|
1598
1629
|
await els[0].press('End')
|
|
1599
1630
|
await els[0].type(value.toString(), { delay: this.options.pressKeyDelay })
|
|
@@ -1639,20 +1670,12 @@ class Puppeteer extends Helper {
|
|
|
1639
1670
|
|
|
1640
1671
|
const targetEls = els.length ? els : await this._locate(locator)
|
|
1641
1672
|
assertElementExists(targetEls, locator, 'Element')
|
|
1642
|
-
const
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
for (let i = 0; i < binaryStr.length; i++) bytes[i] = binaryStr.charCodeAt(i)
|
|
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 fileData = {
|
|
1674
|
+
base64Content: base64EncodeFile(file),
|
|
1675
|
+
fileName: path.basename(file),
|
|
1676
|
+
mimeType: getMimeType(path.basename(file)),
|
|
1677
|
+
}
|
|
1678
|
+
await targetEls[0].evaluate(dropFile, fileData)
|
|
1656
1679
|
return this._waitForAction()
|
|
1657
1680
|
}
|
|
1658
1681
|
|
|
@@ -3098,6 +3121,7 @@ async function proceedClick(locator, context = null, options = {}) {
|
|
|
3098
3121
|
} else {
|
|
3099
3122
|
assertElementExists(els, locator, 'Clickable element')
|
|
3100
3123
|
}
|
|
3124
|
+
if (this.options.strict) assertOnlyOneElement(els, locator, this)
|
|
3101
3125
|
|
|
3102
3126
|
highlightActiveElement.call(this, els[0], await this._getContext())
|
|
3103
3127
|
|
|
@@ -3425,6 +3449,13 @@ function assertElementExists(res, locator, prefix, suffix) {
|
|
|
3425
3449
|
}
|
|
3426
3450
|
}
|
|
3427
3451
|
|
|
3452
|
+
function assertOnlyOneElement(elements, locator, helper) {
|
|
3453
|
+
if (elements.length > 1) {
|
|
3454
|
+
const webElements = elements.map(el => new WebElement(el, helper))
|
|
3455
|
+
throw new MultipleElementsFound(locator, webElements)
|
|
3456
|
+
}
|
|
3457
|
+
}
|
|
3458
|
+
|
|
3428
3459
|
function $XPath(element, selector) {
|
|
3429
3460
|
const found = document.evaluate(selector, element || document.body, null, 5, null)
|
|
3430
3461
|
const res = []
|
package/lib/helper/WebDriver.js
CHANGED
|
@@ -30,12 +30,14 @@ 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'
|
|
41
43
|
|
|
@@ -503,6 +505,7 @@ class WebDriver extends Helper {
|
|
|
503
505
|
keepBrowserState: false,
|
|
504
506
|
deprecationWarnings: false,
|
|
505
507
|
highlightElement: false,
|
|
508
|
+
strict: false,
|
|
506
509
|
}
|
|
507
510
|
|
|
508
511
|
// override defaults with config
|
|
@@ -1090,6 +1093,7 @@ class WebDriver extends Helper {
|
|
|
1090
1093
|
} else {
|
|
1091
1094
|
assertElementExists(res, locator, 'Clickable element')
|
|
1092
1095
|
}
|
|
1096
|
+
if (this.options.strict) assertOnlyOneElement(res, locator, this)
|
|
1093
1097
|
const elem = usingFirstElement(res)
|
|
1094
1098
|
highlightActiveElement.call(this, elem)
|
|
1095
1099
|
return this.browser[clickMethod](getElementId(elem))
|
|
@@ -1109,6 +1113,7 @@ class WebDriver extends Helper {
|
|
|
1109
1113
|
} else {
|
|
1110
1114
|
assertElementExists(res, locator, 'Clickable element')
|
|
1111
1115
|
}
|
|
1116
|
+
if (this.options.strict) assertOnlyOneElement(res, locator, this)
|
|
1112
1117
|
const elem = usingFirstElement(res)
|
|
1113
1118
|
highlightActiveElement.call(this, elem)
|
|
1114
1119
|
|
|
@@ -1136,6 +1141,7 @@ class WebDriver extends Helper {
|
|
|
1136
1141
|
} else {
|
|
1137
1142
|
assertElementExists(res, locator, 'Clickable element')
|
|
1138
1143
|
}
|
|
1144
|
+
if (this.options.strict) assertOnlyOneElement(res, locator, this)
|
|
1139
1145
|
|
|
1140
1146
|
const elem = usingFirstElement(res)
|
|
1141
1147
|
highlightActiveElement.call(this, elem)
|
|
@@ -1156,6 +1162,7 @@ class WebDriver extends Helper {
|
|
|
1156
1162
|
} else {
|
|
1157
1163
|
assertElementExists(res, locator, 'Clickable element')
|
|
1158
1164
|
}
|
|
1165
|
+
if (this.options.strict) assertOnlyOneElement(res, locator, this)
|
|
1159
1166
|
|
|
1160
1167
|
const el = usingFirstElement(res)
|
|
1161
1168
|
|
|
@@ -1272,6 +1279,7 @@ class WebDriver extends Helper {
|
|
|
1272
1279
|
async fillField(field, value, context = null) {
|
|
1273
1280
|
const res = await findFields.call(this, field, context)
|
|
1274
1281
|
assertElementExists(res, field, 'Field')
|
|
1282
|
+
if (this.options.strict) assertOnlyOneElement(res, field, this)
|
|
1275
1283
|
const elem = usingFirstElement(res)
|
|
1276
1284
|
highlightActiveElement.call(this, elem)
|
|
1277
1285
|
try {
|
|
@@ -1295,6 +1303,7 @@ class WebDriver extends Helper {
|
|
|
1295
1303
|
async appendField(field, value, context = null) {
|
|
1296
1304
|
const res = await findFields.call(this, field, context)
|
|
1297
1305
|
assertElementExists(res, field, 'Field')
|
|
1306
|
+
if (this.options.strict) assertOnlyOneElement(res, field, this)
|
|
1298
1307
|
const elem = usingFirstElement(res)
|
|
1299
1308
|
highlightActiveElement.call(this, elem)
|
|
1300
1309
|
return elem.addValue(value.toString())
|
|
@@ -1307,6 +1316,7 @@ class WebDriver extends Helper {
|
|
|
1307
1316
|
async clearField(field, context = null) {
|
|
1308
1317
|
const res = await findFields.call(this, field, context)
|
|
1309
1318
|
assertElementExists(res, field, 'Field')
|
|
1319
|
+
if (this.options.strict) assertOnlyOneElement(res, field, this)
|
|
1310
1320
|
const elem = usingFirstElement(res)
|
|
1311
1321
|
highlightActiveElement.call(this, elem)
|
|
1312
1322
|
return elem.clearValue(getElementId(elem))
|
|
@@ -1376,20 +1386,12 @@ class WebDriver extends Helper {
|
|
|
1376
1386
|
const targetRes = res.length ? res : await this._locate(locator)
|
|
1377
1387
|
assertElementExists(targetRes, locator, 'Element')
|
|
1378
1388
|
const targetEl = usingFirstElement(targetRes)
|
|
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 })
|
|
1389
|
+
const fileData = {
|
|
1390
|
+
base64Content: base64EncodeFile(file),
|
|
1391
|
+
fileName: path.basename(file),
|
|
1392
|
+
mimeType: getMimeType(path.basename(file)),
|
|
1393
|
+
}
|
|
1394
|
+
return this.browser.execute(dropFile, targetEl, fileData)
|
|
1393
1395
|
}
|
|
1394
1396
|
|
|
1395
1397
|
/**
|
|
@@ -1974,8 +1976,22 @@ class WebDriver extends Helper {
|
|
|
1974
1976
|
* {{> moveCursorTo }}
|
|
1975
1977
|
*/
|
|
1976
1978
|
async moveCursorTo(locator, xOffset, yOffset) {
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
+
let context = null
|
|
1980
|
+
if (typeof xOffset !== 'number' && xOffset !== undefined) {
|
|
1981
|
+
context = xOffset
|
|
1982
|
+
xOffset = undefined
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1985
|
+
let res
|
|
1986
|
+
if (context) {
|
|
1987
|
+
const contextRes = await this._locate(withStrictLocator(context), true)
|
|
1988
|
+
assertElementExists(contextRes, context, 'Context element')
|
|
1989
|
+
res = await contextRes[0].$$(withStrictLocator(locator))
|
|
1990
|
+
assertElementExists(res, locator)
|
|
1991
|
+
} else {
|
|
1992
|
+
res = await this._locate(withStrictLocator(locator), true)
|
|
1993
|
+
assertElementExists(res, locator)
|
|
1994
|
+
}
|
|
1979
1995
|
const elem = usingFirstElement(res)
|
|
1980
1996
|
try {
|
|
1981
1997
|
await elem.moveTo({ xOffset, yOffset })
|
|
@@ -3289,6 +3305,13 @@ function usingFirstElement(els) {
|
|
|
3289
3305
|
return els[0]
|
|
3290
3306
|
}
|
|
3291
3307
|
|
|
3308
|
+
function assertOnlyOneElement(elements, locator, helper) {
|
|
3309
|
+
if (elements.length > 1) {
|
|
3310
|
+
const webElements = Array.from(elements).map(el => new WebElement(el, helper))
|
|
3311
|
+
throw new MultipleElementsFound(locator, webElements)
|
|
3312
|
+
}
|
|
3313
|
+
}
|
|
3314
|
+
|
|
3292
3315
|
function getElementId(el) {
|
|
3293
3316
|
// W3C WebDriver web element identifier
|
|
3294
3317
|
// 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,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
|