diff --git a/src/browser_functions.js b/src/browser_functions.js new file mode 100644 index 00000000..3efafdfa --- /dev/null +++ b/src/browser_functions.js @@ -0,0 +1,136 @@ + +/** + * @param {import('puppeteer').Page} page + * @param {Array} selectors + * @returns {Element | null} + */ +async function queryElement(page, selectors) { + const newSelectors = selectors.map(selector => { + return typeof selector !== 'string' + ? {source: selector.source, flags: selector.flags} + : selector; + }); + + return await page.evaluate(selectors => { + /** @type {Array} */ + let elements = [document]; + for (let i = 0; i < selectors.length; i++) { + let selector = selectors[i]; + + let j = elements.length; + while (j--) { + let element = elements[j]; + + // Skip current node if it is a text node and we don't match text next + if (element.nodeType === Node.TEXT_NODE) { + if (typeof selector === 'string' && !selector.startsWith('text=')) { + elements.splice(j, 1); + continue; + } + } + + let node = /** @type {Element} */ (element); + + if (typeof selector === 'object' || selector.startsWith('text=')) { + // eslint-disable-next-line no-undef + /** @type {(text: string) => boolean} */ + let matchFunc; + /** @type {null | (text: string) => boolean} */ + let matchFuncExact = null; + + if (typeof selector === 'string') { + matchFunc = text => text.includes(selector.slice('text='.length)); + } else { + const regexExact = new RegExp(selector.source, selector.flags); + matchFuncExact = text => { + // Reset regex state in case global flag was used + regexExact.lastIndex = 0; + return regexExact.test(text); + }; + + // Remove leading ^ and ending $, otherwise the traversal + // will fail at the first node. + const source = selector.source.replace(/^[^]/, '').replace(/[$]$/, ''); + const regex = new RegExp(source, selector.flags); + matchFunc = text => { + // Reset regex state in case global flag was used + regex.lastIndex = 0; + return regex.test(text); + }; + } + + // `document.textContent` always returns `null`, so we need + // to ensure that we're starting with `document.body` instead + node = node === document ? document.body : node; + const stack = [node]; + let item = null; + let lastFound = null; + while ((item = stack.pop())) { + for (let k = 0; k < item.childNodes.length; k++) { + const child = item.childNodes[k]; + + // Skip text nodes as they are not clickable + if (child.nodeType === Node.TEXT_NODE) { + continue; + } + + const text = child.textContent || ''; + if (child.childNodes.length > 0 && matchFunc(text)) { + if (matchFuncExact === null || matchFuncExact(text)) { + lastFound = child; + } + stack.push(child); + } + } + } + + if (!lastFound) { + elements.splice(j, 1); + } else { + elements[j] = lastFound; + } + } else if (/^\.?\/\/[a-zA-z]/.test(selector) || selector.startsWith('xpath=')) { + // The double slashes at the start signal that the XPath will always + // resolve against the document root. That is not what we want so we + // need to make it relative. + selector = '.' + selector; + const lastFound = document + .evaluate(selector, node, null, window.XPathResult.ANY_TYPE, null) + .iterateNext(); + + if (!lastFound) { + elements.splice(j, 1); + } else { + elements[j] = lastFound; + } + } else { + if (selector.startsWith('testid=')) { + const testid = selector.slice('testid='.length); + selector = `[data-testid="${testid}"]`; + } + + const result = node.querySelectorAll(selector); + + if (result.length > 0) { + node.querySelectorAll(selector).forEach((child, i) => { + if (i > 0) { + elements.push(child); + } else { + elements[j] = child; + } + }); + } else { + elements.splice(j, 1); + } + } + } + } + + // TODO: Support multiple elements + return elements.length > 0 ? elements[0] : null; + }, newSelectors); +} + +module.exports = { + queryElement, +}; diff --git a/src/browser_utils.js b/src/browser_utils.js index 79a27c46..fc8c2a0f 100644 --- a/src/browser_utils.js +++ b/src/browser_utils.js @@ -557,6 +557,325 @@ async function closePage(page) { addBreadcrumb(config, 'exit closePage()'); } + +/** + * @param {import('puppeteer').Page} page + * @param {Array} selectors + * @returns {Element | null} + */ +async function queryElement(page, selectors) { + const newSelectors = selectors.map(selector => { + return typeof selector !== 'string' + ? {source: selector.source, flags: selector.flags} + : selector; + }); + + return await page.evaluate(selectors => { + /** @type {Array} */ + let elements = [document]; + for (let i = 0; i < selectors.length; i++) { + let selector = selectors[i]; + + let j = elements.length; + while (j--) { + let element = elements[j]; + + // Skip current node if it is a text node and we don't match text next + if (element.nodeType === Node.TEXT_NODE) { + if (typeof selector === 'string' && !selector.startsWith('text=')) { + elements.splice(j, 1); + continue; + } + } + + let node = /** @type {Element} */ (element); + + if (typeof selector === 'object' || selector.startsWith('text=')) { + // eslint-disable-next-line no-undef + /** @type {(text: string) => boolean} */ + let matchFunc; + /** @type {null | (text: string) => boolean} */ + let matchFuncExact = null; + + if (typeof selector === 'string') { + matchFunc = text => text.includes(selector.slice('tetxt='.length)); + } else { + const regexExact = new RegExp(selector.source, selector.flags); + matchFuncExact = text => { + // Reset regex state in case global flag was used + regexExact.lastIndex = 0; + return regexExact.test(text); + }; + + // Remove leading ^ and ending $, otherwise the traversal + // will fail at the first node. + const source = selector.source.replace(/^[^]/, '').replace(/[$]$/, ''); + const regex = new RegExp(source, selector.flags); + matchFunc = text => { + // Reset regex state in case global flag was used + regex.lastIndex = 0; + return regex.test(text); + }; + } + + // `document.textContent` always returns `null`, so we need + // to ensure that we're starting with `document.body` instead + node = node === document ? document.body : node; + const stack = [node]; + let item = null; + let lastFound = null; + while ((item = stack.pop())) { + for (let k = 0; k < item.childNodes.length; k++) { + const child = item.childNodes[k]; + + // Skip text nodes as they are not clickable + if (child.nodeType === Node.TEXT_NODE) { + continue; + } + + const text = child.textContent || ''; + if (child.childNodes.length > 0 && matchFunc(text)) { + if (matchFuncExact === null || matchFuncExact(text)) { + lastFound = child; + } + stack.push(child); + } + } + } + + if (!lastFound) { + elements.splice(j, 1); + } else { + elements[j] = lastFound; + } + } else if (/^\.?\/\/[a-zA-z]/.test(selector) || selector.startsWith('xpath=')) { + // The double slashes at the start signal that the XPath will always + // resolve against the document root. That is not what we want so we + // need to make it relative. + selector = '.' + selector; + const lastFound = document + .evaluate(selector, node, null, window.XPathResult.ANY_TYPE, null) + .iterateNext(); + + if (!lastFound) { + elements.splice(j, 1); + } else { + elements[j] = lastFound; + } + } else { + if (selector.startsWith('testid=')) { + const testid = selector.slice('testid='.length); + selector = `[data-testid="${testid}"]`; + } + + const result = node.querySelectorAll(selector); + + if (result.length > 0) { + node.querySelectorAll(selector).forEach((child, i) => { + if (i > 0) { + elements.push(child); + } else { + elements[j] = child; + } + }); + } else { + elements.splice(j, 1); + } + } + } + } + + // TODO: Support multiple elements + return elements.length > 0 ? elements[0] : null; + }, newSelectors); +} + +/** + * + * @param {import('puppeteer').Page} page + * @param {import('puppeteer').ElementHandle} element + * @returns {Promise} + */ +async function getElementCoordinates(page, element) { + /** @type {null, | {world: { x: number, y: number }, local: { x: number, y: number }}} */ + return await page.evaluateHandle(async (element) => { + if (element.offsetParent === null) return null; // invisible + + /** + * Get the center coordinates of our element to click on. + * @type {DOMRect} + */ + let rect; + + // Text nodes don't have `getBoundingClientRect()`, but + // we can use range objects for that. + if (element.nodeType === Node.TEXT_NODE) { + // Element may be hidden in a scroll container + element.parentNode.scrollIntoView({ + block: 'center', + inline: 'center', + behavior: 'instant', + }); + + const visibleRatio = await new Promise(resolve => { + const observer = new IntersectionObserver(entries => { + resolve(entries[0].intersectionRatio); + observer.disconnect(); + }); + observer.observe(element.parentNode); + }); + if (visibleRatio !== 1.0) { + element.scrollIntoView({block: 'center', inline: 'center', behavior: 'instant'}); + } + + const range = document.createRange(); + range.selectNodeContents(element); + + const rects = range.getClientRects(); + if (!rects || rects.length < 1) { + throw new Error(`Could not determine Text node coordinates of "${element.data}"`); + } + + rect = rects[0]; + } else { + // Element may be hidden in a scroll container + element.scrollIntoView({block: 'center', inline: 'center', behavior: 'instant'}); + const visibleRatio = await new Promise(resolve => { + const observer = new IntersectionObserver(entries => { + resolve(entries[0].intersectionRatio); + observer.disconnect(); + }); + observer.observe(element); + }); + if (visibleRatio !== 1.0) { + element.scrollIntoView({block: 'center', inline: 'center', behavior: 'instant'}); + } + + rect = /** @type {Element} */ (element).getBoundingClientRect(); + } + + const localX = rect.x + rect.width / 2; + const localY = rect.y + rect.height / 2; + + let x = localX; + let y = localY; + + // Account for offset of the current frame if we are inside an iframe + let win = window; + let parentWin = null; + while (win !== window.top) { + parentWin = win.parent; + + const iframe = Array.from(parentWin.document.querySelectorAll('iframe')).find( + f => f.contentWindow === win + ); + if (iframe) { + const iframeRect = iframe.getBoundingClientRect(); + x += iframeRect.x; + y += iframeRect.y; + break; + } + } + + // Check if the element is obscured by another + const isTopmostElement = document.elementFromPoint(localX, localY) === element; + + return { + world: { x, y }, + local: { x: localX, y: localY }, + isTopmostElement, + }; + }, element); +} + +/** + * Query the DOM with multiple chained selector types + * @param {import('./config').Config} config + * @param {import('puppeteer').Page} page + * @param {Array} selectors + * @param {{visible?: boolean}} options + */ +async function queryOrClick(config, page, selectors, { visible } = {}) { + let found = false; + try { + const element = await queryElement(page, selectors); + if (!element) { + found = false; + } else if (visible) { + const coordinates = await getElementCoordinates(page, element); + if (coordinates !== null) { + found = coordinates; + } + } else { + // Click on invisible elements + await element.click(); + found = true; + } + + // Simulate a true mouse click. The following function scrolls + // the element into view, moves the mouse to its center and + // presses the left mouse button. This is important for when + // an element is above the one we want to click. + if (found !== null && typeof found === 'object') { + await getMouse(page).click(found.world.x, found.world.y); + } + + return found; + } catch (err) { + if (!ignoreError(err)) { + throw await enhanceError(config, page, err); + } + } + + return found; +} + +/** + * Query the DOM with multiple chained selector types + * @param {import('./config').Config} config + * @param {import('puppeteer').Page} page + * @param {Array} selectors + * @param {{ visible?: boolean, timeout?: number, checkEvery?: number }} options + * @returns {Promise} + */ +async function waitForElement(config, page, selectors, { visible, timeout = getDefaultTimeout(page), checkEvery = 200 } = {}) { + let remainingTimeout = timeout; + // eslint-disable-next-line no-constant-condition + while (true) { + try { + const element = await queryElement(page, selectors); + + if (element && visible) { + const coordinates = await getElementCoordinates(page, element); + if (coordinates !== null && coordinates.isTopmostElement) { + // Check if the element is on top and not overlapped by another + const isTopmostElement = await page.evaluateHandle((element, coordinates) => { + const expected = document.elementFromPoint(coordinates.local.x, coordinates.local.y); + return expected === element; + }, coordinates); + + if (!isTopmostElement) { + return null; + } + } + } + + return element; + } catch (err) { + if (!ignoreError(err)) { + throw await enhanceError(config, page, err); + } + } + + if (remainingTimeout <= 0) { + break; + } + + await wait(Math.min(checkEvery, remainingTimeout)); + remainingTimeout -= checkEvery; + } +} + /** * Wait for an element matched by a CSS query selector to become present on the page. * @@ -574,6 +893,8 @@ async function waitForSelector(page, selector, {message=undefined, timeout=getDe let el; try { + el = await waitForElement(config, page, [selector], { visible }); + el = await page.waitForFunction((qs, visible) => { const all = document.querySelectorAll(qs); if (all.length < 1) return null; @@ -871,99 +1192,28 @@ function getMouse(pageOrFrame) { } /** - * Clicks an element address ed by a query selector atomically, e.g. within the same event loop run as finding it. * - * @example - * ```javascript - * await clickSelector(page, 'div[data-id="foo"] a.view', {message: 'Could not click foo link'}); - * ``` - * @param {import('puppeteer').Page | import('puppeteer').Frame} page puppeteer page object. - * @param {string} selector [CSS selector](https://www.w3.org/TR/2018/REC-selectors-3-20181106/#selectors) (aka query selector) of the targeted element. - * @param {{timeout?: number, checkEvery?: number, message?: string, visible?: boolean, assertSuccess?: () => Promise, retryUntil?: () => Promise}} [__namedParameters] Options (currently not visible in output due to typedoc bug) - * @param {string?} message Error message shown if the element is not visible in time. - * @param {number?} timeout How long to wait, in milliseconds. - * @param {number?} checkEvery How long to wait _between_ checks, in ms. (default: 200ms) - * @param {boolean?} visible Whether the element must be visible within the timeout. (default: `true`) - * @param {() => Promise?} assertSuccess Deprecated: Alias of retryUntil - * @param {() => Promise?} retryUntil Additional check to verify that the operation was successful. This is needed in cases where a DOM node is present - * and we clicked on it, but the framework that rendered the node didn't set up any event listeners yet. + * @param {import('puppeteer').Page} page + * @param {string | RegExp | Array} selector + * @param {{timeout?: number, checkEvery?: number, visible?: boolean, retryUntil?: () => any, message?: string}} options */ -async function clickSelector(page, selector, {timeout=getDefaultTimeout(page), checkEvery=200, message=undefined, visible=true, assertSuccess, retryUntil} = {}) { +async function click(page, selector, { timeout = getDefaultTimeout(page), checkEvery = 200, message, visible = true, retryUntil}) { + const selectors = Array.isArray(selector) ? selector : [selector]; + const config = getBrowser(page)._pentf_config; - addBreadcrumb(config, `enter clickSelector(${selector})`); + addBreadcrumb(config, `enter click(${selectors.join(', ')})`); assert.equal(typeof selector, 'string', 'CSS selector should be string (forgot page argument?)'); let remainingTimeout = timeout; let retryUntilError = null; // eslint-disable-next-line no-constant-condition while (true) { - let found = false; - try { - found = await page.evaluate(async (selector, visible) => { - const element = document.querySelector(selector); - if (!element) return false; - - if (visible) { - if (element.offsetParent === null) return null; // invisible - - // Element may be hidden in a scroll container - element.scrollIntoView({block: 'center', inline: 'center', behavior: 'instant'}); - const visibleRatio = await new Promise(resolve => { - const observer = new IntersectionObserver(entries => { - resolve(entries[0].intersectionRatio); - observer.disconnect(); - }); - observer.observe(element); - }); - if (visibleRatio !== 1.0) { - element.scrollIntoView({block: 'center', inline: 'center', behavior: 'instant'}); - } - - const rect = /** @type {Element} */ (element).getBoundingClientRect(); - let x = rect.x + (rect.width / 2); - let y = rect.y + (rect.height / 2); - - // Account for offset of the current frame if we are inside an iframe - let win = window; - let parentWin = null; - while (win !== window.top) { - parentWin = win.parent; - - const iframe = Array.from(parentWin.document.querySelectorAll('iframe')) - .find(f => f.contentWindow === win); - if (iframe) { - const iframeRect = iframe.getBoundingClientRect(); - x += iframeRect.x; - y += iframeRect.y; - break; - } - } - return { x, y }; - } - - // We can't use the mouse to click on invisible elements. - // Therefore invoke the click handler on the DOM node directly. - element.click(); - return true; - }, selector, visible); - - // Simulate a true mouse click. The following function scrolls - // the element into view, moves the mouse to its center and - // presses the left mouse button. This is important for when - // an element is above the one we want to click. - if (found !== null && typeof found === 'object') { - await getMouse(page).click(found.x, found.y); - } - } catch(err) { - if (!ignoreError(err)) { - throw await enhanceError(config, page, err); - } - } + const found = await queryOrClick(config, page, [selector], { click: true, visible }); try { - if ((found || (!found && retryUntilError !== null)) && (await onSuccess(retryUntil || assertSuccess))) { + if ((found || (!found && retryUntilError !== null)) && (await onSuccess(retryUntil))) { const config = getBrowser(page)._pentf_config; - addBreadcrumb(config, `exit clickSelector(${selector})`); + addBreadcrumb(config, `exit click(${selectors.join(', ')})`); return; } } catch (err) { @@ -976,7 +1226,7 @@ async function clickSelector(page, selector, {timeout=getDefaultTimeout(page), c } if (!message) { - message = `Unable to find ${visible ? 'visible ' : ''}element ${selector} after ${timeout}ms`; + message = `Unable to find ${visible ? 'visible ' : ''}element ${selectors.join(', ')} after ${timeout}ms`; } throw await enhanceError(config, page, new Error(message)); } @@ -985,6 +1235,37 @@ async function clickSelector(page, selector, {timeout=getDefaultTimeout(page), c } } +/** + * Clicks an element addressed by a query selector atomically, e.g. within the same event loop run as finding it. + * + * @example + * ```javascript + * await clickSelector(page, 'div[data-id="foo"] a.view', {message: 'Could not click foo link'}); + * ``` + * @param {import('puppeteer').Page | import('puppeteer').Frame} page puppeteer page object. + * @param {string} selector [CSS selector](https://www.w3.org/TR/2018/REC-selectors-3-20181106/#selectors) (aka query selector) of the targeted element. + * @param {{timeout?: number, checkEvery?: number, message?: string, visible?: boolean, assertSuccess?: () => Promise, retryUntil?: () => Promise}} [__namedParameters] Options (currently not visible in output due to typedoc bug) + * @param {string?} message Error message shown if the element is not visible in time. + * @param {number?} timeout How long to wait, in milliseconds. + * @param {number?} checkEvery How long to wait _between_ checks, in ms. (default: 200ms) + * @param {boolean?} visible Whether the element must be visible within the timeout. (default: `true`) + * @param {() => Promise?} assertSuccess Deprecated: Alias of retryUntil + * @param {() => Promise?} retryUntil Additional check to verify that the operation was successful. This is needed in cases where a DOM node is present + * and we clicked on it, but the framework that rendered the node didn't set up any event listeners yet. + */ +async function clickSelector(page, selector, {message, timeout, checkEvery, visible, assertSuccess, retryUntil} = {}) { + const config = getBrowser(page)._pentf_config; + addBreadcrumb(config, `enter clickSelector(${selector})`); + assert.equal(typeof selector, 'string', 'CSS selector should be string (forgot page argument?)'); + + try { + await click(page, selector, {message, timeout, checkEvery, visible, retryUntil: retryUntil || assertSuccess}); + } catch (err) { + addBreadcrumb(config, `exit clickSelector(${selector})`); + throw err; + } +} + /** * Asserts that a selector is not present in the passed page or frame. * @@ -1036,130 +1317,11 @@ async function clickXPath(page, xpath, {timeout=getDefaultTimeout(page), checkEv addBreadcrumb(config, `enter clickXPath(${xpath})`); assert.equal(typeof xpath, 'string', 'XPath should be string (forgot page argument?)'); - let remainingTimeout = timeout; - let retryUntilError = null; - // eslint-disable-next-line no-constant-condition - while (true) { - let found = false; - try { - found = await page.evaluate(async (xpath, visible) => { - /** @type {Element | Text} */ - const element = document.evaluate( - xpath, document, null, window.XPathResult.ANY_TYPE, null).iterateNext(); - if (!element) return false; - - if (visible) { - if (element.offsetParent === null) return null; // invisible - - /** - * Get the center coordinates of our element to click on. - * @type {DOMRect} - */ - let rect; - - // Text nodes don't have `getBoundingClientRect()`, but - // we can use range objects for that. - if (element.nodeType === Node.TEXT_NODE) { - // Element may be hidden in a scroll container - element.parentNode.scrollIntoView({block: 'center', inline: 'center', behavior: 'instant'}); - - const visibleRatio = await new Promise(resolve => { - const observer = new IntersectionObserver(entries => { - resolve(entries[0].intersectionRatio); - observer.disconnect(); - }); - observer.observe(element.parentNode); - }); - if (visibleRatio !== 1.0) { - element.scrollIntoView({block: 'center', inline: 'center', behavior: 'instant'}); - } - - const range = document.createRange(); - range.selectNodeContents(element); - - const rects = range.getClientRects(); - if (!rects || rects.length < 1) { - throw new Error(`Could not determine Text node coordinates of "${element.data}"`); - } - - rect = rects[0]; - } else { - // Element may be hidden in a scroll container - element.scrollIntoView({block: 'center', inline: 'center', behavior: 'instant'}); - const visibleRatio = await new Promise(resolve => { - const observer = new IntersectionObserver(entries => { - resolve(entries[0].intersectionRatio); - observer.disconnect(); - }); - observer.observe(element); - }); - if (visibleRatio !== 1.0) { - element.scrollIntoView({block: 'center', inline: 'center', behavior: 'instant'}); - } - - rect = /** @type {Element} */ (element).getBoundingClientRect(); - } - - let x = rect.x + (rect.width / 2); - let y = rect.y + (rect.height / 2); - - // Account for offset of the current frame if we are inside an iframe - let win = window; - let parentWin = null; - while (win !== window.top) { - parentWin = win.parent; - - const iframe = Array.from(parentWin.document.querySelectorAll('iframe')) - .find(f => f.contentWindow === win); - if (iframe) { - const iframeRect = iframe.getBoundingClientRect(); - x += iframeRect.x; - y += iframeRect.y; - break; - } - } - return { x, y }; - } - - // Click on invisible elements - element.click(); - return true; - }, xpath, visible); - - // Simulate a true mouse click. The following function scrolls - // the element into view, moves the mouse to its center and - // presses the left mouse button. This is important for when - // an element is above the one we want to click. - if (found !== null && typeof found === 'object') { - await getMouse(page).click(found.x, found.y); - } - } catch (err) { - if (!ignoreError(err)) { - throw await enhanceError(config, page, err); - } - } - - try { - if ((found || (!found && retryUntilError !== null)) && await onSuccess(retryUntil || assertSuccess)) { - addBreadcrumb(config, `exit clickXPath(${xpath})`); - return; - } - } catch (err) { - retryUntilError = err; - } - - if (remainingTimeout <= 0) { - if (retryUntilError) { - throw retryUntilError; - } - - if (!message) { - message = `Unable to find XPath ${xpath} after ${timeout}ms`; - } - throw await enhanceError(config, page, new Error(message)); - } - await wait(Math.min(remainingTimeout, checkEvery)); - remainingTimeout -= checkEvery; + try { + await click(page, xpath, {message, timeout, checkEvery, visible, retryUntil: retryUntil || assertSuccess}); + } catch (err) { + addBreadcrumb(config, `exit clickXPath(${xpath})`); + throw err; } } @@ -1218,149 +1380,17 @@ async function clickText(page, text, {timeout=getDefaultTimeout(page), checkEver async function clickNestedText(page, textOrRegExp, {timeout=getDefaultTimeout(page), checkEvery=200, extraMessage=undefined, visible=true, assertSuccess, retryUntil}={}) { const config = getBrowser(page)._pentf_config; addBreadcrumb(config, `enter clickNestedText(${textOrRegExp})`); + let selector = textOrRegExp; if (typeof textOrRegExp === 'string') { checkText(textOrRegExp); + selector = 'text=' + textOrRegExp; } - const serializedMatcher = typeof textOrRegExp !== 'string' - ? {source: textOrRegExp.source, flags: textOrRegExp.flags} - : textOrRegExp; - - let remainingTimeout = timeout; - let retryUntilError = null; - // eslint-disable-next-line no-constant-condition - while (true) { - let found = false; - - try { - found = await page.evaluate(async (matcher, visible) => { - // eslint-disable-next-line no-undef - /** @type {(text: string) => boolean} */ - let matchFunc; - /** @type {null | (text: string) => boolean} */ - let matchFuncExact = null; - - if (typeof matcher == 'string') { - matchFunc = text => text.includes(matcher); - } else { - const regexExact = new RegExp(matcher.source, matcher.flags); - matchFuncExact = text => { - // Reset regex state in case global flag was used - regexExact.lastIndex = 0; - return regexExact.test(text); - }; - - // Remove leading ^ and ending $, otherwise the traversal - // will fail at the first node. - const source = matcher.source - .replace(/^[^]/, '') - .replace(/[$]$/, ''); - const regex = new RegExp(source, matcher.flags); - matchFunc = text => { - // Reset regex state in case global flag was used - regex.lastIndex = 0; - return regex.test(text); - }; - } - - const stack = [document.body]; - let item = null; - let lastFound = null; - while ((item = stack.pop())) { - for (let i = 0; i < item.childNodes.length; i++) { - const child = item.childNodes[i]; - - // Skip text nodes as they are not clickable - if (child.nodeType === Node.TEXT_NODE) { - continue; - } - - const text = child.textContent || ''; - if (child.childNodes.length > 0 && matchFunc(text)) { - if (matchFuncExact === null || matchFuncExact(text)) { - lastFound = child; - } - stack.push(child); - } - } - } - - if (!lastFound) return false; - - if (visible) { - if (lastFound.offsetParent === null) return null; // invisible) - - // Element may be hidden in a scroll container - lastFound.scrollIntoView({block: 'center', inline: 'center', behavior: 'instant'}); - const visibleRatio = await new Promise(resolve => { - const observer = new IntersectionObserver(entries => { - resolve(entries[0].intersectionRatio); - observer.disconnect(); - }); - observer.observe(lastFound); - }); - if (visibleRatio !== 1.0) { - lastFound.scrollIntoView({block: 'center', inline: 'center', behavior: 'instant'}); - } - - const rect = lastFound.getBoundingClientRect(); - let x = rect.x + (rect.width / 2); - let y = rect.y + (rect.height / 2); - - // Account for offset of the current frame if we are inside an iframe - let win = window; - let parentWin = null; - while (win !== window.top) { - parentWin = win.parent; - - const iframe = Array.from(parentWin.document.querySelectorAll('iframe')) - .find(f => f.contentWindow === win); - if (iframe) { - const iframeRect = iframe.getBoundingClientRect(); - x += iframeRect.x; - y += iframeRect.y; - break; - } - } - return { x, y }; - } - - lastFound.click(); - return true; - }, serializedMatcher, visible); - - // Simulate a true mouse click. The following function scrolls - // the element into view, moves the mouse to its center and - // presses the left mouse button. This is important for when - // an element is above the one we want to click. - if (found !== null && typeof found === 'object') { - await getMouse(page).click(found.x, found.y); - } - } catch (err) { - if (!ignoreError(err)) { - throw await enhanceError(config, page, err); - } - } - - try { - if ((found || (!found && retryUntilError !== null)) && await onSuccess(retryUntil || assertSuccess)) { - addBreadcrumb(config, `exit clickNestedText(${textOrRegExp})`); - return; - } - } catch (err) { - retryUntilError = err; - } - - if (remainingTimeout <= 0) { - if (retryUntilError) { - throw retryUntilError; - } - - const extraMessageRepr = extraMessage ? ` (${extraMessage})` : ''; - throw await enhanceError(config, page, new Error(`Unable to find${visible ? ' visible' : ''} text "${textOrRegExp}" after ${timeout}ms${extraMessageRepr}`)); - } - await wait(Math.min(remainingTimeout, checkEvery)); - remainingTimeout -= checkEvery; + try { + await click(page, selector, {message: extraMessage, timeout, checkEvery, visible, retryUntil: retryUntil || assertSuccess}); + } catch (err) { + addBreadcrumb(config, `exit clickNestedText(${textOrRegExp})`); + throw err; } } @@ -1382,10 +1412,9 @@ async function clickTestId(page, testId, {extraMessage=undefined, timeout=getDef addBreadcrumb(config, `enter clickTestId(${testId})`); _checkTestId(testId); - const xpath = `//*[@data-testid="${testId}"]`; const extraMessageRepr = extraMessage ? `. ${extraMessage}` : ''; const message = `Failed to find${visible ? ' visible' : ''} element with data-testid "${testId}" within ${timeout}ms${extraMessageRepr}`; - const res = await clickXPath(page, xpath, {timeout, message, visible, retryUntil: retryUntil || assertSuccess}); + const res = await click(page, `testid=${testId}`, {timeout, message, visible, retryUntil: retryUntil || assertSuccess}); addBreadcrumb(config, `exit clickTestId(${testId})`); return res; } @@ -1924,6 +1953,7 @@ module.exports = { assertNotXPath, assertSnapshot, assertValue, + click, clickNestedText, clickSelector, clickTestId, diff --git a/src/internal.ts b/src/internal.ts index 0d7e449f..d8b2697c 100644 --- a/src/internal.ts +++ b/src/internal.ts @@ -145,3 +145,14 @@ export type A11yResult = { description: string; nodes: A11yNode[]; } + +export interface Point { + x: number; + y: number; +} + +export interface Coordinates { + world: Point; + local: Point; + isTopmostElement: boolean; +}