diff --git a/lib/helper/Puppeteer.js b/lib/helper/Puppeteer.js index 0b417d768..35115ab00 100644 --- a/lib/helper/Puppeteer.js +++ b/lib/helper/Puppeteer.js @@ -634,9 +634,11 @@ class Puppeteer extends Helper { return } - const els = await this._locate(locator) - assertElementExists(els, locator) - this.context = els[0] + const el = await this._locateElement(locator) + if (!el) { + throw new ElementNotFound(locator, 'Element for within context') + } + this.context = el this.withinLocator = new Locator(locator) } @@ -730,11 +732,13 @@ class Puppeteer extends Helper { * {{ react }} */ async moveCursorTo(locator, offsetX = 0, offsetY = 0) { - const els = await this._locate(locator) - assertElementExists(els, locator) + const el = await this._locateElement(locator) + if (!el) { + throw new ElementNotFound(locator, 'Element to move cursor to') + } // Use manual mouse.move instead of .hover() so the offset can be added to the coordinates - const { x, y } = await getClickablePoint(els[0]) + const { x, y } = await getClickablePoint(el) await this.page.mouse.move(x + offsetX, y + offsetY) return this._waitForAction() } @@ -744,9 +748,10 @@ class Puppeteer extends Helper { * */ async focus(locator) { - const els = await this._locate(locator) - assertElementExists(els, locator, 'Element to focus') - const el = els[0] + const el = await this._locateElement(locator) + if (!el) { + throw new ElementNotFound(locator, 'Element to focus') + } await el.click() await el.focus() @@ -758,10 +763,12 @@ class Puppeteer extends Helper { * */ async blur(locator) { - const els = await this._locate(locator) - assertElementExists(els, locator, 'Element to blur') + const el = await this._locateElement(locator) + if (!el) { + throw new ElementNotFound(locator, 'Element to blur') + } - await blurElement(els[0], this.page) + await blurElement(el, this.page) return this._waitForAction() } @@ -810,11 +817,12 @@ class Puppeteer extends Helper { } if (locator) { - const els = await this._locate(locator) - assertElementExists(els, locator, 'Element') - const el = els[0] + const el = await this._locateElement(locator) + if (!el) { + throw new ElementNotFound(locator, 'Element to scroll into view') + } await el.evaluate(el => el.scrollIntoView()) - const elementCoordinates = await getClickablePoint(els[0]) + const elementCoordinates = await getClickablePoint(el) await this.executeScript((x, y) => window.scrollBy(x, y), elementCoordinates.x + offsetX, elementCoordinates.y + offsetY) } else { await this.executeScript((x, y) => window.scrollTo(x, y), offsetX, offsetY) @@ -882,6 +890,21 @@ class Puppeteer extends Helper { return findElements.call(this, context, locator) } + /** + * Get single element by different locator types, including strict locator + * Should be used in custom helpers: + * + * ```js + * const element = await this.helpers['Puppeteer']._locateElement({name: 'password'}); + * ``` + * + * {{ react }} + */ + async _locateElement(locator) { + const context = await this.context + return findElement.call(this, context, locator) + } + /** * Find a checkbox by providing human-readable text: * NOTE: Assumes the checkable element exists @@ -893,7 +916,9 @@ class Puppeteer extends Helper { async _locateCheckable(locator, providedContext = null) { const context = providedContext || (await this._getContext()) const els = await findCheckable.call(this, locator, context) - assertElementExists(els[0], locator, 'Checkbox or radio') + if (!els || els.length === 0) { + throw new ElementNotFound(locator, 'Checkbox or radio') + } return els[0] } @@ -2124,10 +2149,12 @@ class Puppeteer extends Helper { * {{> waitForClickable }} */ async waitForClickable(locator, waitTimeout) { - const els = await this._locate(locator) - assertElementExists(els, locator) + const el = await this._locateElement(locator) + if (!el) { + throw new ElementNotFound(locator, 'Element to wait for clickable') + } - return this.waitForFunction(isElementClickable, [els[0]], waitTimeout).catch(async e => { + return this.waitForFunction(isElementClickable, [el], waitTimeout).catch(async e => { if (/Waiting failed/i.test(e.message) || /failed: timeout/i.test(e.message)) { throw new Error(`element ${new Locator(locator).toString()} still not clickable after ${waitTimeout || this.options.waitForTimeout / 1000} sec`) } else { @@ -2701,9 +2728,18 @@ class Puppeteer extends Helper { module.exports = Puppeteer +/** + * Find elements using Puppeteer's native element discovery methods + * Note: Unlike Playwright, Puppeteer's Locator API doesn't have .all() method for multiple elements + * @param {Object} matcher - Puppeteer context to search within + * @param {Object|string} locator - Locator specification + * @returns {Promise} Array of ElementHandle objects + */ async function findElements(matcher, locator) { if (locator.react) return findReactElements.call(this, locator) locator = new Locator(locator, 'css') + + // Use proven legacy approach - Puppeteer Locator API doesn't have .all() method if (!locator.isXPath()) return matcher.$$(locator.simplify()) // puppeteer version < 19.4.0 is no longer supported. This one is backward support. if (puppeteer.default?.defaultBrowserRevision) { @@ -2712,6 +2748,31 @@ async function findElements(matcher, locator) { return matcher.$x(locator.value) } +/** + * Find a single element using Puppeteer's native element discovery methods + * Note: Puppeteer Locator API doesn't have .first() method like Playwright + * @param {Object} matcher - Puppeteer context to search within + * @param {Object|string} locator - Locator specification + * @returns {Promise} Single ElementHandle object + */ +async function findElement(matcher, locator) { + if (locator.react) return findReactElements.call(this, locator) + locator = new Locator(locator, 'css') + + // Use proven legacy approach - Puppeteer Locator API doesn't have .first() method + if (!locator.isXPath()) { + const elements = await matcher.$$(locator.simplify()) + return elements[0] + } + // puppeteer version < 19.4.0 is no longer supported. This one is backward support. + if (puppeteer.default?.defaultBrowserRevision) { + const elements = await matcher.$$(`xpath/${locator.value}`) + return elements[0] + } + const elements = await matcher.$x(locator.value) + return elements[0] +} + async function proceedClick(locator, context = null, options = {}) { let matcher = await this.context if (context) { @@ -2857,15 +2918,19 @@ async function findFields(locator) { } async function proceedDragAndDrop(sourceLocator, destinationLocator) { - const src = await this._locate(sourceLocator) - assertElementExists(src, sourceLocator, 'Source Element') + const src = await this._locateElement(sourceLocator) + if (!src) { + throw new ElementNotFound(sourceLocator, 'Source Element') + } - const dst = await this._locate(destinationLocator) - assertElementExists(dst, destinationLocator, 'Destination Element') + const dst = await this._locateElement(destinationLocator) + if (!dst) { + throw new ElementNotFound(destinationLocator, 'Destination Element') + } - // Note: Using public api .getClickablePoint becaues the .BoundingBox does not take into account iframe offsets - const dragSource = await getClickablePoint(src[0]) - const dragDestination = await getClickablePoint(dst[0]) + // Note: Using public api .getClickablePoint because the .BoundingBox does not take into account iframe offsets + const dragSource = await getClickablePoint(src) + const dragDestination = await getClickablePoint(dst) // Drag start point await this.page.mouse.move(dragSource.x, dragSource.y, { steps: 5 })