Skip to content

Commit

Permalink
feat: add traverseShadowRoots option to toMatch (#463)
Browse files Browse the repository at this point in the history
* feat: adding a new matcher for shadow DOM toMatch

* fix: fixing incorrect page setup

* fix: rewording export in toMatchInShadow

* fix: moving toMatchInShadow into toMatch
  • Loading branch information
k-j-kim committed Jan 18, 2022
1 parent 3f85633 commit 28c5235
Show file tree
Hide file tree
Showing 8 changed files with 237 additions and 100 deletions.
1 change: 1 addition & 0 deletions packages/expect-puppeteer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
49 changes: 47 additions & 2 deletions packages/expect-puppeteer/src/matchers/notToMatch.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}"`)
Expand Down
84 changes: 46 additions & 38 deletions packages/expect-puppeteer/src/matchers/notToMatch.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
}
})
})
},
)
})
28 changes: 18 additions & 10 deletions packages/expect-puppeteer/src/matchers/setupPage.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
})
}
51 changes: 48 additions & 3 deletions packages/expect-puppeteer/src/matchers/toMatch.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,33 +3,78 @@ 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)

const { text, regexp } = expandSearchExpr(matcher)

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
},
options,
handle,
text,
regexp,
traverseShadowRoots,
)
} catch (error) {
throw enhanceError(error, `Text not found "${matcher}"`)
Expand Down
102 changes: 55 additions & 47 deletions packages/expect-puppeteer/src/matchers/toMatch.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
}
})
})
},
)
})
Loading

0 comments on commit 28c5235

Please sign in to comment.