Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for puppeteer custom query handlers #352

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .travis.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
language: node_js

node_js:
- 8
- 10
- 12

Expand Down
4 changes: 4 additions & 0 deletions examples/create-react-app/integration/app.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ describe('app', () => {
await expect(page).toMatchElement('.App-button', { text: 'Get Started' })
})

it('should match elements with text using custom selector defined in jest.globalSetup', async () => {
await expect(page).toMatchElement('hasText/Get Started')
})

it('should match a input with a "textInput" name then fill it with text', async () => {
await expect(page).toFill('input[name="textInput"]', 'James')
})
Expand Down
1 change: 1 addition & 0 deletions examples/create-react-app/integration/jest.config.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
module.exports = {
preset: 'jest-puppeteer',
globalSetup: './jest.globalSetup.js',
testRegex: './*\\.test\\.js$',
}
29 changes: 29 additions & 0 deletions examples/create-react-app/integration/jest.globalSetup.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/* eslint import/no-extraneous-dependencies:off */
const puppeteer = require('puppeteer')
const { setup: setupPuppeteer } = require('jest-environment-puppeteer')

module.exports = async function globalSetup(globalConfig) {
await setupPuppeteer(globalConfig)

registerCustomQuery()
}

// Create a simple custom query handler to ensure `toMatchElement` supports them
// https://github.com/puppeteer/puppeteer/blob/master/src/QueryHandler.ts
function registerCustomQuery() {
const name = '__experimental_registerCustomQueryHandler'
const register = puppeteer[name]

if (typeof register !== 'function') {
throw new Error(`Puppeteer is missing query handler registration.

Expected "${name}" function
`)
}

register('hasText', function hasText(element, text) {
const result = element.querySelectorAll(`*:not(:empty)`)

return Array.from(result).filter(el => el.textContent.trim() === text)
})
}
8 changes: 6 additions & 2 deletions examples/create-react-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,12 @@
"eject": "react-scripts eject"
},
"devDependencies": {
"jest-puppeteer": "^4.2.0",
"puppeteer": "^2.0.0"
"expect-puppeteer": "file:../../packages/expect-puppeteer",
"jest-dev-server": "file:../../packages/jest-dev-server",
"jest-environment-puppeteer": "file:../../packages/jest-environment-puppeteer",
"jest-puppeteer": "file:../../packages/jest-puppeteer",
"jest-puppeteer-preset": "file:../../packages/jest-puppeteer-preset",
"puppeteer": "^3.0.3"
},
"browserslist": {
"development": [
Expand Down
5,498 changes: 2,850 additions & 2,648 deletions examples/create-react-app/yarn.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
"lerna": "^3.19.0",
"lint-staged": "^9.5.0",
"prettier": "^1.19.1",
"puppeteer": "^2.0.0",
"puppeteer": "^3.1.0",
"puppeteer-firefox": "^0.5.0"
},
"dependencies": {}
Expand Down
31 changes: 12 additions & 19 deletions packages/expect-puppeteer/src/matchers/notToMatchElement.js
Original file line number Diff line number Diff line change
@@ -1,32 +1,25 @@
import { getContext, enhanceError } from '../utils'
import { enhanceError } from '../utils'
import { defaultOptions } from '../options'
import toMatchElement from './toMatchElement'

async function notToMatchElement(
instance,
selector,
{ text, ...options } = {},
{ text, hidden, visible, ...options } = {},
) {
options = defaultOptions(options)

const { page, handle } = await getContext(instance, () => document)
if (hidden) {
options.visible = true
} else {
options.hidden = true
}

try {
await page.waitForFunction(
(handle, selector, text) => {
const elements = handle.querySelectorAll(selector)
if (text !== undefined) {
return [...elements].every(
({ textContent }) => !textContent.match(text),
)
}

return elements.length === 0
},
options,
handle,
selector,
const element = await toMatchElement(instance, selector, {
...options,
text,
)
})
return !element
} catch (error) {
throw enhanceError(
error,
Expand Down
25 changes: 25 additions & 0 deletions packages/expect-puppeteer/src/matchers/notToMatchElement.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,31 @@ describe('not.toMatchElement', () => {
await expect(main).not.toMatchElement('main')
})

it('should not match using xpath selector', async () => {
await expect(page).not.toMatchElement({
value: '//a[contains(@href,"/page3.html")]',
type: 'xpath',
})
})

it('should not match hidden', async () => {
expect.assertions(4)

await expect(page).not.toMatchElement('.displayedWithClassname', {
visible: true,
})
try {
await expect(page).toMatchElement('.displayedWithClassname', {
visible: true,
})
} catch (error) {
expect(error.message).toMatch(
'Element .displayedWithClassname not found',
)
expect(error.message).toMatch('waiting for function failed')
}
})

it('should match using text', async () => {
const main = await page.$('main')
await expect(main).not.toMatchElement('div', { text: 'Nothing here' })
Expand Down
157 changes: 87 additions & 70 deletions packages/expect-puppeteer/src/matchers/toMatchElement.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,102 +4,119 @@ import { defaultOptions } from '../options'
async function toMatchElement(
instance,
selector,
{ text: searchExpr, visible = false, ...options } = {},
{ text: searchExpr, ...options } = {},
) {
options = defaultOptions(options)
selector = selector instanceof Object ? { ...selector } : { type: 'css', value: selector };
selector =
selector instanceof Object
? { ...selector }
: { type: 'css', value: selector }

const { page, handle } = await getContext(instance, () => document)

const { text, regexp } = expandSearchExpr(searchExpr)

const getElement = (handle, selector, text, regexp, visible) => {
function hasVisibleBoundingBox(element) {
const rect = element.getBoundingClientRect()
return !!(rect.top || rect.bottom || rect.width || rect.height)
}
try {
return await waitForElement(Date.now() + options.timeout)
} catch (error) {
throw enhanceError(
error,
`Element ${selector.value}${
text !== null || regexp !== null ? ` (text: "${text || regexp}") ` : ' '
}not found`,
)
}

const isVisible = element => {
if (visible) {
const style = window.getComputedStyle(element)
return (
style &&
style.visibility !== 'hidden' &&
hasVisibleBoundingBox(element)
)
}

return true
}
// Use a custom function that is retried during timeout period instead of
// waitForFunction, waitForSelector, etc.
//
// waitForFunction isn't used because it runs on the browser and would have
// to implement custom selector logic. Instead, use Puppeteer query apis
// (specifically JSHandle.$$) to get access to improvements made there,
// particularly custom selection handlers (registerCustomQueryHandler.)
//
// waitForSelector can't be used because this needs all elements that match
// the selector to check text content, and it only returns the first, which
// may match but not have the text we're looking for.
async function waitForElement(timeout) {
const result = await getElement(handle, selector)

if (!result && options.hidden) return undefined
if (await elementMatchesVisibilityOptions(result)) return result

if (Date.now() > timeout) throw new Error('waiting for function failed')

await page.waitFor(30)
return waitForElement(timeout)
}

// Check if the element handle matches the given visibility options:
// hidden, visible, etc.
function elementMatchesVisibilityOptions(result) {
if (!result) return false
if (result && !options.visible && !options.hidden) return true

return page.evaluate(
(result, waitForVisible, waitForHidden) => {
const element =
result.nodeType === Node.TEXT_NODE ? result.parentElement : result

let nodes = [];
switch (selector.type) {
case 'xpath': {
const xpathResults = document.evaluate(selector.value, handle)
let currentXpathResult = xpathResults.iterateNext();
const style = window.getComputedStyle(element)
const isVisible =
style && style.visibility !== 'hidden' && hasVisibleBoundingBox()
const success =
waitForVisible === isVisible || waitForHidden === !isVisible
return success

while (currentXpathResult) {
nodes.push(currentXpathResult)
currentXpathResult = xpathResults.iterateNext();
function hasVisibleBoundingBox() {
const rect = element.getBoundingClientRect()
return !!(rect.top || rect.bottom || rect.width || rect.height)
}
break;
}
case 'css':
nodes = handle.querySelectorAll(selector.value)
break;
default:
throw new Error(`${selector.type} is not implemented`)
}
},
result,
options.visible,
options.hidden,
)
}

const elements = [...nodes].filter(isVisible)
if (regexp !== null) {
// Get element using JSHandle queries so the selector matches exactly to puppeteer $,$$,waitForSelector apis.
// Allows toMatchElement to work with custom selector handlers `puppeteer.registerCustomQueryHandler`
async function getElement(handle, { type, value: selector }) {
const list = await Promise.all(
(await handle[type === 'xpath' ? '$x' : '$$'](selector)).map(
async handle => {
if (text || regexp)
return {
handle,
textContent: await page.evaluate(e => e.textContent, handle),
}
return { handle }
},
),
)

let found = list[0]

if (regexp) {
const [, pattern, flags] = regexp.match(/\/(.*)\/(.*)?/)
return elements.find(({ textContent }) =>
found = list.find(({ textContent }) =>
textContent
.replace(/\s+/g, ' ')
.trim()
.match(new RegExp(pattern, flags)),
)
}
if (text !== null) {
return elements.find(({ textContent }) =>

if (text) {
found = list.find(({ textContent }) =>
textContent
.replace(/\s+/g, ' ')
.trim()
.includes(text),
)
}
return elements[0]
}

try {
await page.waitForFunction(
getElement,
options,
handle,
selector,
text,
regexp,
visible,
)
} catch (error) {
throw enhanceError(
error,
`Element ${selector.value}${
text !== null || regexp !== null ? ` (text: "${text || regexp}") ` : ' '
}not found`,
)
return found && found.handle
}

const jsHandle = await page.evaluateHandle(
getElement,
handle,
selector,
text,
regexp,
visible,
)
return jsHandle.asElement()
}

export default toMatchElement
14 changes: 14 additions & 0 deletions packages/expect-puppeteer/src/matchers/toMatchElement.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,20 @@ describe('toMatchElement', () => {
expect(error.message).toMatch('waiting for function failed')
}
})

it('should match using hidden options', async () => {
const normalElement = await expect(page).toMatchElement(
'.normalUntilClick',
{
visible: true,
},
)
const textContentProperty = await normalElement.getProperty('textContent')
const textContent = await textContentProperty.jsonValue()
expect(textContent).toBe('normalUntilClick element')
await expect(page).toClick('.normalUntilClick')
await expect(page).toMatchElement('.normalUntilClick', { hidden: true })
})
})

describe('ElementHandle', () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/jest-puppeteer-preset/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"chrome-headless"
],
"peerDependencies": {
"puppeteer": ">= 1.5.0 < 3"
"puppeteer": ">= 1.5.0 <= 3"
},
"dependencies": {
"expect-puppeteer": "^4.4.0",
Expand Down
2 changes: 1 addition & 1 deletion packages/jest-puppeteer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"chrome-headless"
],
"peerDependencies": {
"puppeteer": ">= 1.5.0 < 3"
"puppeteer": ">= 1.5.0 <= 3"
},
"dependencies": {
"expect-puppeteer": "^4.4.0",
Expand Down
Loading