From 0ff02edc00c139957e7b2d2ee2a0441ade120c43 Mon Sep 17 00:00:00 2001 From: Marvin Hagemeister Date: Thu, 25 Feb 2021 19:46:16 +0100 Subject: [PATCH 1/5] Extract common selector code --- src/browser_utils.js | 517 ++++++++++++++++++++----------------------- 1 file changed, 241 insertions(+), 276 deletions(-) diff --git a/src/browser_utils.js b/src/browser_utils.js index 79a27c46..124b53ee 100644 --- a/src/browser_utils.js +++ b/src/browser_utils.js @@ -557,6 +557,242 @@ async function closePage(page) { addBreadcrumb(config, 'exit closePage()'); } +/** + * Query the DOM with multiple chained selector types + * @param {import('./config').Config} config + * @param {import('puppeteer').Page} page + * @param {Array} selectors + * @param {{click?: boolean, visible?: boolean}} options + */ +async function queryOrClick(config, page, selectors, { click, visible } = {}) { + const newSelectors = selectors.map(selector => { + return typeof selector !== 'string' + ? {source: selector.source, flags: selector.flags} + : selector; + }); + + let found = false; + try { + found = await page.evaluate(async (/** @type {Array} */ selectors, visible, click) => { + /** @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 (selector.startsWith('//')) { + // 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 + const element = elements.length > 0 ? elements[0] : null; + + if (!element) return false; + + if (visible) { + if (element.offsetParent === null) return null; // invisible + + if (click) { + /** + * 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 }; + } + } + + if (click) { + // Click on invisible elements + element.click(); + } + + return true; + }, newSelectors, visible, click); + + // 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); + } + } + + return found; +} + /** * Wait for an element matched by a CSS query selector to become present on the page. * @@ -897,68 +1133,7 @@ async function clickSelector(page, selector, {timeout=getDefaultTimeout(page), c 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))) { @@ -1040,105 +1215,7 @@ async function clickXPath(page, xpath, {timeout=getDefaultTimeout(page), checkEv 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); - } - } - + const found = await queryOrClick(config, page, [xpath], {click: true, visible}); try { if ((found || (!found && retryUntilError !== null)) && await onSuccess(retryUntil || assertSuccess)) { addBreadcrumb(config, `exit clickXPath(${xpath})`); @@ -1218,129 +1295,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); - } - } + const found = await queryOrClick(config, page, [selector], { click: true, visible }); try { if ((found || (!found && retryUntilError !== null)) && await onSuccess(retryUntil || assertSuccess)) { From 92118e877131d4618d08f4b6bd2892458f3d5bba Mon Sep 17 00:00:00 2001 From: Marvin Hagemeister Date: Thu, 25 Feb 2021 20:00:46 +0100 Subject: [PATCH 2/5] Unify click functions --- src/browser_utils.js | 126 ++++++++++++++++++------------------------- 1 file changed, 53 insertions(+), 73 deletions(-) diff --git a/src/browser_utils.js b/src/browser_utils.js index 124b53ee..e78de2c7 100644 --- a/src/browser_utils.js +++ b/src/browser_utils.js @@ -1107,26 +1107,16 @@ 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; @@ -1136,9 +1126,9 @@ async function clickSelector(page, selector, {timeout=getDefaultTimeout(page), c 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) { @@ -1151,7 +1141,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)); } @@ -1160,6 +1150,37 @@ async function clickSelector(page, selector, {timeout=getDefaultTimeout(page), c } } +/** + * 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. + */ +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. * @@ -1211,32 +1232,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) { - const found = await queryOrClick(config, page, [xpath], {click: true, visible}); - 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; } } @@ -1301,31 +1301,11 @@ async function clickNestedText(page, textOrRegExp, {timeout=getDefaultTimeout(pa selector = 'text=' + textOrRegExp; } - let remainingTimeout = timeout; - let retryUntilError = null; - // eslint-disable-next-line no-constant-condition - while (true) { - const found = await queryOrClick(config, page, [selector], { click: true, visible }); - - 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; } } @@ -1347,10 +1327,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; } @@ -1889,6 +1868,7 @@ module.exports = { assertNotXPath, assertSnapshot, assertValue, + click, clickNestedText, clickSelector, clickTestId, From 9d2f9d840ac735cf323a49cebe61cc3ec7324839 Mon Sep 17 00:00:00 2001 From: Marvin Hagemeister Date: Fri, 26 Feb 2021 15:01:50 +0100 Subject: [PATCH 3/5] WIP --- src/browser_utils.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/browser_utils.js b/src/browser_utils.js index e78de2c7..e539856f 100644 --- a/src/browser_utils.js +++ b/src/browser_utils.js @@ -1151,7 +1151,7 @@ async function click(page, selector, { timeout = getDefaultTimeout(page), checkE } /** - * Clicks an element address ed by a query selector atomically, e.g. within the same event loop run as finding it. + * Clicks an element addressed by a query selector atomically, e.g. within the same event loop run as finding it. * * @example * ```javascript From 60645cededd6efe24b14eeb76081031876264484 Mon Sep 17 00:00:00 2001 From: Marvin Hagemeister Date: Mon, 1 Mar 2021 19:54:07 +0100 Subject: [PATCH 4/5] WIP --- src/browser_functions.js | 136 ++++++++++++ src/browser_utils.js | 443 ++++++++++++++++++++++----------------- 2 files changed, 392 insertions(+), 187 deletions(-) create mode 100644 src/browser_functions.js 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 e539856f..732dbc8f 100644 --- a/src/browser_utils.js +++ b/src/browser_utils.js @@ -557,233 +557,266 @@ async function closePage(page) { addBreadcrumb(config, 'exit closePage()'); } + /** - * Query the DOM with multiple chained selector types - * @param {import('./config').Config} config * @param {import('puppeteer').Page} page - * @param {Array} selectors - * @param {{click?: boolean, visible?: boolean}} options + * @param {Array} selectors + * @returns {Element | null} */ -async function queryOrClick(config, page, selectors, { click, visible } = {}) { +async function queryElement(page, selectors) { const newSelectors = selectors.map(selector => { return typeof selector !== 'string' ? {source: selector.source, flags: selector.flags} : selector; }); - let found = false; - try { - found = await page.evaluate(async (/** @type {Array} */ selectors, visible, click) => { - /** @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; - } + 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); - }; - } + let node = /** @type {Element} */ (element); - // `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; - } + 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; - const text = child.textContent || ''; - if (child.childNodes.length > 0 && matchFunc(text)) { - if (matchFuncExact === null || matchFuncExact(text)) { - lastFound = child; - } - stack.push(child); + 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 (selector.startsWith('//')) { - // 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; - } + if (!lastFound) { + elements.splice(j, 1); } else { - if (selector.startsWith('testid=')) { - const testid = selector.slice('testid='.length); - selector = `[data-testid="${testid}"]`; - } + 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); + 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); - } + 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 - const element = elements.length > 0 ? elements[0] : null; - - if (!element) return false; - - if (visible) { - if (element.offsetParent === null) return null; // invisible - - if (click) { - /** - * 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'}); - } + // TODO: Support multiple elements + return elements.length > 0 ? elements[0] : null; + }, newSelectors); +} - const range = document.createRange(); - range.selectNodeContents(element); +/** + * + * @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 rects = range.getClientRects(); - if (!rects || rects.length < 1) { - throw new Error(`Could not determine Text node coordinates of "${element.data}"`); - } + 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'}); + } - 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'}); - } + const range = document.createRange(); + range.selectNodeContents(element); - rect = /** @type {Element} */ (element).getBoundingClientRect(); - } + const rects = range.getClientRects(); + if (!rects || rects.length < 1) { + throw new Error(`Could not determine Text node coordinates of "${element.data}"`); + } - 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 }; - } + 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'}); } - if (click) { - // Click on invisible elements - element.click(); + 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; } + } - return true; - }, newSelectors, visible, click); + return { + world: { x, y }, + local: { x: localX, y: localY } + }; + }, 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; + + if (visible) { + const coordinates = await getElementCoordinates(); + 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.x, found.y); + await getMouse(page).click(found.world.x, found.world.y); } + + return found; } catch (err) { if (!ignoreError(err)) { throw await enhanceError(config, page, err); @@ -793,6 +826,41 @@ async function queryOrClick(config, page, selectors, { click, visible } = {}) { 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 }} options + * @returns {Promise} + */ +async function waitForElement(config, page, selectors, { visible } = {}) { + try { + const element = await queryElement(page, selectors); + + if (element && visible) { + const coordinates = await getElementCoordinates(); + if (coordinates !== null) { + // 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); + } + } +} + /** * Wait for an element matched by a CSS query selector to become present on the page. * @@ -810,6 +878,7 @@ 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; From b22575cd9094b871700fdf3f049e17aac67bc65b Mon Sep 17 00:00:00 2001 From: Marvin Hagemeister Date: Mon, 22 Mar 2021 12:26:33 +0100 Subject: [PATCH 5/5] WIP --- src/browser_utils.js | 68 +++++++++++++++++++++++++++----------------- src/internal.ts | 11 +++++++ 2 files changed, 53 insertions(+), 26 deletions(-) diff --git a/src/browser_utils.js b/src/browser_utils.js index 732dbc8f..fc8c2a0f 100644 --- a/src/browser_utils.js +++ b/src/browser_utils.js @@ -694,7 +694,7 @@ async function queryElement(page, selectors) { * * @param {import('puppeteer').Page} page * @param {import('puppeteer').ElementHandle} element - * @returns {Promise} + * @returns {Promise} */ async function getElementCoordinates(page, element) { /** @type {null, | {world: { x: number, y: number }, local: { x: number, y: number }}} */ @@ -777,9 +777,13 @@ async function getElementCoordinates(page, element) { } } + // Check if the element is obscured by another + const isTopmostElement = document.elementFromPoint(localX, localY) === element; + return { world: { x, y }, - local: { x: localX, y: localY } + local: { x: localX, y: localY }, + isTopmostElement, }; }, element); } @@ -795,10 +799,10 @@ async function queryOrClick(config, page, selectors, { visible } = {}) { let found = false; try { const element = await queryElement(page, selectors); - if (!element) found = false; - - if (visible) { - const coordinates = await getElementCoordinates(); + if (!element) { + found = false; + } else if (visible) { + const coordinates = await getElementCoordinates(page, element); if (coordinates !== null) { found = coordinates; } @@ -831,33 +835,44 @@ async function queryOrClick(config, page, selectors, { visible } = {}) { * @param {import('./config').Config} config * @param {import('puppeteer').Page} page * @param {Array} selectors - * @param {{ visible?: boolean }} options + * @param {{ visible?: boolean, timeout?: number, checkEvery?: number }} options * @returns {Promise} */ -async function waitForElement(config, page, selectors, { visible } = {}) { - try { - const element = await queryElement(page, selectors); - - if (element && visible) { - const coordinates = await getElementCoordinates(); - if (coordinates !== null) { - // 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; +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); + } } - 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; } } @@ -879,6 +894,7 @@ 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; 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; +}