From 28c523563c2943e686bca2b418cba8e0b794059e Mon Sep 17 00:00:00 2001 From: k-j-kim <17989954+k-j-kim@users.noreply.github.com> Date: Mon, 17 Jan 2022 17:08:26 -0800 Subject: [PATCH] feat: add traverseShadowRoots option to toMatch (#463) * feat: adding a new matcher for shadow DOM toMatch * fix: fixing incorrect page setup * fix: rewording export in toMatchInShadow * fix: moving toMatchInShadow into toMatch --- packages/expect-puppeteer/README.md | 1 + .../src/matchers/notToMatch.js | 49 ++++++++- .../src/matchers/notToMatch.test.js | 84 ++++++++------- .../src/matchers/setupPage.js | 28 +++-- .../expect-puppeteer/src/matchers/toMatch.js | 51 ++++++++- .../src/matchers/toMatch.test.js | 102 ++++++++++-------- server/public/shadow.html | 21 ++++ server/public/shadowFrame.html | 1 + 8 files changed, 237 insertions(+), 100 deletions(-) create mode 100644 server/public/shadow.html create mode 100644 server/public/shadowFrame.html diff --git a/packages/expect-puppeteer/README.md b/packages/expect-puppeteer/README.md index 8a7a073c..0c133bf0 100644 --- a/packages/expect-puppeteer/README.md +++ b/packages/expect-puppeteer/README.md @@ -164,6 +164,7 @@ Expect a text or a string RegExp to be present in the page or element. - `raf` - to constantly execute `pageFunction` in `requestAnimationFrame` callback. This is the tightest polling mode which is suitable to observe styling changes. - `mutation` - to execute `pageFunction` on every DOM mutation. - `timeout` <[number]> maximum time to wait for in milliseconds. Defaults to `30000` (30 seconds). Pass `0` to disable timeout. The default value can be changed by using the [page.setDefaultTimeout(timeout)](#pagesetdefaulttimeouttimeout) method. + - `traverseShadowRoots`<[boolean]> Whether shadow roots should be traversed to find a match. ```js // Matching using text diff --git a/packages/expect-puppeteer/src/matchers/notToMatch.js b/packages/expect-puppeteer/src/matchers/notToMatch.js index bbe45e2c..b6f3a6f5 100644 --- a/packages/expect-puppeteer/src/matchers/notToMatch.js +++ b/packages/expect-puppeteer/src/matchers/notToMatch.js @@ -3,18 +3,63 @@ import { defaultOptions } from '../options' async function notToMatch(instance, matcher, options) { options = defaultOptions(options) + const { traverseShadowRoots = false } = options const { page, handle } = await getContext(instance, () => document.body) try { await page.waitForFunction( - (handle, matcher) => { + (handle, matcher, traverseShadowRoots) => { + function getShadowTextContent(node) { + const walker = document.createTreeWalker( + node, + // eslint-disable-next-line no-bitwise + NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT, + null, + false, + ) + let result = '' + let currentNode = walker.nextNode() + while (currentNode) { + if (currentNode.assignedSlot) { + // Skip everything within this subtree, since it's assigned to a slot in the shadow DOM. + const nodeWithAssignedSlot = currentNode + while ( + currentNode === nodeWithAssignedSlot || + nodeWithAssignedSlot.contains(currentNode) + ) { + currentNode = walker.nextNode() + } + // eslint-disable-next-line no-continue + continue + } else if (currentNode.nodeType === Node.TEXT_NODE) { + result += currentNode.textContent + } else if (currentNode.shadowRoot) { + result += getShadowTextContent(currentNode.shadowRoot) + } else if (typeof currentNode.assignedNodes === 'function') { + const assignedNodes = currentNode.assignedNodes() + // eslint-disable-next-line no-loop-func + assignedNodes.forEach((node) => { + result += getShadowTextContent(node) + }) + } + currentNode = walker.nextNode() + } + return result + } + if (!handle) return false - return handle.textContent.match(new RegExp(matcher)) === null + + const textContent = traverseShadowRoots + ? getShadowTextContent(handle) + : handle.textContent + + return textContent.match(new RegExp(matcher)) === null }, options, handle, matcher, + traverseShadowRoots, ) } catch (error) { throw enhanceError(error, `Text found "${matcher}"`) diff --git a/packages/expect-puppeteer/src/matchers/notToMatch.test.js b/packages/expect-puppeteer/src/matchers/notToMatch.test.js index fb1ee401..6fdb3076 100644 --- a/packages/expect-puppeteer/src/matchers/notToMatch.test.js +++ b/packages/expect-puppeteer/src/matchers/notToMatch.test.js @@ -5,43 +5,51 @@ describe('not.toMatch', () => { await page.goto(`http://localhost:${process.env.TEST_SERVER_PORT}`) }) - describe.each(['Page', 'Frame'])('%s', (pageType) => { - let page - setupPage(pageType, ({ currentPage }) => { - page = currentPage - }) - it('should be ok if text is not in the page', async () => { - await expect(page).not.toMatch('Nop!') - }) - - it('should return an error if text is in the page', async () => { - expect.assertions(3) - - try { - await expect(page).not.toMatch('home') - } catch (error) { - expect(error.message).toMatch('Text found "home"') - expect(error.message).toMatch('waiting for function failed') - } - }) - }) + describe.each(['Page', 'Frame', 'ShadowPage', 'ShadowFrame'])( + '%s', + (pageType) => { + let page + setupPage(pageType, ({ currentPage }) => { + page = currentPage + }) - describe('ElementHandle', () => { - it('should be ok if text is in the page', async () => { - const dialogBtn = await page.$('#dialog-btn') - await expect(dialogBtn).not.toMatch('Nop') - }) - - it('should return an error if text is not in the page', async () => { - expect.assertions(3) - const dialogBtn = await page.$('#dialog-btn') - - try { - await expect(dialogBtn).not.toMatch('Open dialog') - } catch (error) { - expect(error.message).toMatch('Text found "Open dialog"') - expect(error.message).toMatch('waiting for function failed') - } - }) - }) + const options = ['ShadowPage', 'ShadowFrame'].includes(pageType) + ? { traverseShadowRoots: true } + : {} + + it('should be ok if text is not in the page', async () => { + await expect(page).not.toMatch('Nop!', options) + }) + + it('should return an error if text is in the page', async () => { + expect.assertions(3) + + try { + await expect(page).not.toMatch('home', options) + } catch (error) { + expect(error.message).toMatch('Text found "home"') + expect(error.message).toMatch('waiting for function failed') + } + }) + + describe('ElementHandle', () => { + it('should be ok if text is in the page', async () => { + const dialogBtn = await page.$('#dialog-btn') + await expect(dialogBtn).not.toMatch('Nop', options) + }) + + it('should return an error if text is not in the page', async () => { + expect.assertions(3) + const dialogBtn = await page.$('#dialog-btn') + + try { + await expect(dialogBtn).not.toMatch('Open dialog', options) + } catch (error) { + expect(error.message).toMatch('Text found "Open dialog"') + expect(error.message).toMatch('waiting for function failed') + } + }) + }) + }, + ) }) diff --git a/packages/expect-puppeteer/src/matchers/setupPage.js b/packages/expect-puppeteer/src/matchers/setupPage.js index 9ea6ffb7..c0e52b1a 100644 --- a/packages/expect-puppeteer/src/matchers/setupPage.js +++ b/packages/expect-puppeteer/src/matchers/setupPage.js @@ -12,21 +12,29 @@ function waitForFrame(page) { return promise } -export const setupPage = (pageType, cb) => { +async function goToPage(page, route, isFrame, cb) { let currentPage = page + await page.goto(`http://localhost:${process.env.TEST_SERVER_PORT}/${route}`) + if (isFrame) { + currentPage = await waitForFrame(page) + } + cb({ + currentPage, + }) +} + +export const setupPage = (pageType, cb) => { beforeEach(async () => { if (pageType === `Page`) { cb({ - currentPage, + currentPage: page, }) - return + } else if (pageType === 'ShadowPage') { + await goToPage(page, 'shadow.html', false, cb) + } else if (pageType === 'ShadowFrame') { + await goToPage(page, 'shadowFrame.html', true, cb) + } else { + await goToPage(page, 'frame.html', true, cb) } - await page.goto( - `http://localhost:${process.env.TEST_SERVER_PORT}/frame.html`, - ) - currentPage = await waitForFrame(page) - cb({ - currentPage, - }) }) } diff --git a/packages/expect-puppeteer/src/matchers/toMatch.js b/packages/expect-puppeteer/src/matchers/toMatch.js index c3f58d6b..f95a0219 100644 --- a/packages/expect-puppeteer/src/matchers/toMatch.js +++ b/packages/expect-puppeteer/src/matchers/toMatch.js @@ -3,6 +3,7 @@ import { defaultOptions } from '../options' async function toMatch(instance, matcher, options) { options = defaultOptions(options) + const { traverseShadowRoots = false } = options const { page, handle } = await getContext(instance, () => document.body) @@ -10,19 +11,62 @@ async function toMatch(instance, matcher, options) { try { await page.waitForFunction( - (handle, text, regexp) => { + (handle, text, regexp, traverseShadowRoots) => { + function getShadowTextContent(node) { + const walker = document.createTreeWalker( + node, + // eslint-disable-next-line no-bitwise + NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT, + null, + false, + ) + let result = '' + let currentNode = walker.nextNode() + while (currentNode) { + if (currentNode.assignedSlot) { + // Skip everything within this subtree, since it's assigned to a slot in the shadow DOM. + const nodeWithAssignedSlot = currentNode + while ( + currentNode === nodeWithAssignedSlot || + nodeWithAssignedSlot.contains(currentNode) + ) { + currentNode = walker.nextNode() + } + // eslint-disable-next-line no-continue + continue + } else if (currentNode.nodeType === Node.TEXT_NODE) { + result += currentNode.textContent + } else if (currentNode.shadowRoot) { + result += getShadowTextContent(currentNode.shadowRoot) + } else if (typeof currentNode.assignedNodes === 'function') { + const assignedNodes = currentNode.assignedNodes() + // eslint-disable-next-line no-loop-func + assignedNodes.forEach((node) => { + result += getShadowTextContent(node) + }) + } + currentNode = walker.nextNode() + } + return result + } + if (!handle) return false + + const textContent = traverseShadowRoots + ? getShadowTextContent(handle) + : handle.textContent + if (regexp !== null) { const [, pattern, flags] = regexp.match(/\/(.*)\/(.*)?/) return ( - handle.textContent + textContent .replace(/\s+/g, ' ') .trim() .match(new RegExp(pattern, flags)) !== null ) } if (text !== null) { - return handle.textContent.replace(/\s+/g, ' ').trim().includes(text) + return textContent.replace(/\s+/g, ' ').trim().includes(text) } return false }, @@ -30,6 +74,7 @@ async function toMatch(instance, matcher, options) { handle, text, regexp, + traverseShadowRoots, ) } catch (error) { throw enhanceError(error, `Text not found "${matcher}"`) diff --git a/packages/expect-puppeteer/src/matchers/toMatch.test.js b/packages/expect-puppeteer/src/matchers/toMatch.test.js index 069f2e1a..bcf65e41 100644 --- a/packages/expect-puppeteer/src/matchers/toMatch.test.js +++ b/packages/expect-puppeteer/src/matchers/toMatch.test.js @@ -5,52 +5,60 @@ describe('toMatch', () => { await page.goto(`http://localhost:${process.env.TEST_SERVER_PORT}`) }) - describe.each(['Page', 'Frame'])('%s', (pageType) => { - let page - setupPage(pageType, ({ currentPage }) => { - page = currentPage - }) - it('should be ok if text is in the page', async () => { - await expect(page).toMatch('This is home!') - }) - - it('should support RegExp', async () => { - await expect(page).toMatch(/THIS.is.home/i) - }) - - it('should return an error if text is not in the page', async () => { - expect.assertions(3) - - try { - await expect(page).toMatch('Nop') - } catch (error) { - expect(error.message).toMatch('Text not found "Nop"') - expect(error.message).toMatch('waiting for function failed') - } - }) - }) + describe.each(['Page', 'Frame', 'ShadowPage', 'ShadowFrame'])( + '%s', + (pageType) => { + let page + setupPage(pageType, ({ currentPage }) => { + page = currentPage + }) - describe('ElementHandle', () => { - it('should be ok if text is in the page', async () => { - const dialogBtn = await page.$('#dialog-btn') - await expect(dialogBtn).toMatch('Open dialog') - }) - - it('should support RegExp', async () => { - const dialogBtn = await page.$('#dialog-btn') - await expect(dialogBtn).toMatch(/OPEN/i) - }) - - it('should return an error if text is not in the page', async () => { - expect.assertions(3) - const dialogBtn = await page.$('#dialog-btn') - - try { - await expect(dialogBtn).toMatch('This is home!') - } catch (error) { - expect(error.message).toMatch('Text not found "This is home!"') - expect(error.message).toMatch('waiting for function failed') - } - }) - }) + const options = ['ShadowPage', 'ShadowFrame'].includes(pageType) + ? { traverseShadowRoots: true } + : {} + + it('should be ok if text is in the page', async () => { + await expect(page).toMatch('This is home!', options) + }) + + it('should support RegExp', async () => { + await expect(page).toMatch(/THIS.is.home/i, options) + }) + + it('should return an error if text is not in the page', async () => { + expect.assertions(3) + + try { + await expect(page).toMatch('Nop', options) + } catch (error) { + expect(error.message).toMatch('Text not found "Nop"') + expect(error.message).toMatch('waiting for function failed') + } + }) + + describe('ElementHandle', () => { + it('should be ok if text is in the page', async () => { + const dialogBtn = await page.$('#dialog-btn') + await expect(dialogBtn).toMatch('Open dialog', options) + }) + + it('should support RegExp', async () => { + const dialogBtn = await page.$('#dialog-btn') + await expect(dialogBtn).toMatch(/OPEN/i, options) + }) + + it('should return an error if text is not in the page', async () => { + expect.assertions(3) + const dialogBtn = await page.$('#dialog-btn') + + try { + await expect(dialogBtn).toMatch('This is home!', options) + } catch (error) { + expect(error.message).toMatch('Text not found "This is home!"') + expect(error.message).toMatch('waiting for function failed') + } + }) + }) + }, + ) }) diff --git a/server/public/shadow.html b/server/public/shadow.html new file mode 100644 index 00000000..d6f546c9 --- /dev/null +++ b/server/public/shadow.html @@ -0,0 +1,21 @@ + + + + + Test App + + + + +

Light DOM content (slotted)

+
+ + + diff --git a/server/public/shadowFrame.html b/server/public/shadowFrame.html new file mode 100644 index 00000000..dea0f840 --- /dev/null +++ b/server/public/shadowFrame.html @@ -0,0 +1 @@ +