Skip to content

Commit

Permalink
feat: add element handle support
Browse files Browse the repository at this point in the history
- Add toMatchElement method
- Make all matchers wait before an element is present
- All matchers accept an ElementHandle or a Page
  • Loading branch information
gregberge committed Mar 16, 2018
1 parent 17e5bec commit 4d37d5b
Show file tree
Hide file tree
Showing 23 changed files with 758 additions and 211 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"format": "prettier --write \"packages/**/*.{js,json,md}\" \"*.{js,json,md}\"",
"lint": "eslint .",
"release": "lerna publish && conventional-github-releaser -p angular",
"test": "jest"
"test": "jest --runInBand"
},
"devDependencies": {
"babel-cli": "^6.26.0",
Expand Down
92 changes: 76 additions & 16 deletions packages/expect-puppeteer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,32 +35,45 @@ To use with Jest, just modify your configuration:
}
```

## Why do I need it

Writing integration test is very hard especially in Single Page Application. Data are loaded asynchronously and it is difficult to know exactly when it will be displayed in the page.

Puppeteer API is great, all this methods are built with it but it is low level and not designed to test an application. This API is designed for integration testing and will wait element before running each action.

## API

##### Table of Contents

<!-- toc -->

* [toClick](#expectpagetoclickselector-options)
* [toDisplayDialog](#expectpagetodisplaydialogblock)
* [toFill](#expectpagetofillselector-value-options)
* [toFillForm](#expectpagetofillformselector-values-options)
* [toMatch](#expectpagetomatchtext)
* [toSelect](#expectpagetoselectselector-valueortext)
* [toUploadFile](#expectpagetouploadfileselector-filepath)
* [toClick](#toClick)
* [toDisplayDialog](#toDisplayDialog)
* [toFill](#toFill)
* [toFillForm](#toFillForm)
* [toMatch](#toMatch)
* [toMatchElement](#toMatchElement)
* [toSelect](#toSelect)
* [toUploadFile](#toUploadFile)

### expect(page).toClick(selector[, options])
### <a name="toClick"></a>expect(instance).toClick(selector[, options])

* `instance` <[Page]|[ElementHandle]> Context
* `selector` <[string]> A [selector] to click on
* `options` <[Object]> Optional parameters
* text <[string]> A text to match
* `polling` <[string]|[number]> An interval at which the `pageFunction` is executed, defaults to `raf`. If `polling` is a number, then it is treated as an interval in milliseconds at which the function would be executed. If `polling` is a string, then it can be one of the following values:
* `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 `500`.
* `text` <[string]> A text or a RegExp to match in element `textContent`.

```js
await expect(page).toClick('button', { text: 'Home' })
```

### expect(page).toDisplayDialog(block)
### <a name="toDisplayDialog"></a>expect(page).toDisplayDialog(block)

* `page` <[Page]> Context
* `block` <[function]> A [function] that should trigger a dialog

```js
Expand All @@ -69,22 +82,30 @@ await expect(page).toDisplayDialog(async () => {
})
```

### expect(page).toFill(selector, value[, options])
### <a name="toFill"></a>expect(instance).toFill(selector, value[, options])

* `instance` <[Page]|[ElementHandle]> Context
* `selector` <[string]> A [selector] to match field
* `value` <[string]> Value to fill
* `options` <[Object]> Optional parameters
* `polling` <[string]|[number]> An interval at which the `pageFunction` is executed, defaults to `raf`. If `polling` is a number, then it is treated as an interval in milliseconds at which the function would be executed. If `polling` is a string, then it can be one of the following values:
* `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 `500`.

```js
await expect(page).toFill('input[name="firstName"]', 'James')
```

### expect(page).toFillForm(selector, values[, options])
### <a name="toFillForm"></a>expect(instance).toFillForm(selector, values[, options])

* `instance` <[Page]|[ElementHandle]> Context
* `selector` <[string]> A [selector] to match form
* `values` <[Object]> Values to fill
* `options` <[Object]> Optional parameters
* `polling` <[string]|[number]> An interval at which the `pageFunction` is executed, defaults to `raf`. If `polling` is a number, then it is treated as an interval in milliseconds at which the function would be executed. If `polling` is a string, then it can be one of the following values:
* `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 `500`.

```js
Expand All @@ -94,29 +115,66 @@ await expect(page).toFillForm('form[name="myForm"]', {
})
```

### expect(page).toMatch(text)
### <a name="toMatch"></a>expect(instance).toMatch(matcher[, options])

* `text` <[string]> A text to match in page
* `instance` <[Page]|[ElementHandle]> Context
* `matcher` <[string]> A text or a RegExp to match in page
* `options` <[Object]> Optional parameters
* `polling` <[string]|[number]> An interval at which the `pageFunction` is executed, defaults to `raf`. If `polling` is a number, then it is treated as an interval in milliseconds at which the function would be executed. If `polling` is a string, then it can be one of the following values:
* `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 `500`.

```js
// Matching using text
await expect(page).toMatch('Lorem ipsum')
// Matching using RegExp
await expect(page).toMatch('lo.*')
```

### expect(page).toSelect(selector, valueOrText)
### <a name="toMatchElement"></a>expect(instance).toMatchElement(selector[, options])

* `instance` <[Page]|[ElementHandle]> Context
* `selector` <[string]> A [selector] to match element
* `options` <[Object]> Optional parameters
* `polling` <[string]|[number]> An interval at which the `pageFunction` is executed, defaults to `raf`. If `polling` is a number, then it is treated as an interval in milliseconds at which the function would be executed. If `polling` is a string, then it can be one of the following values:
* `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 `500`.
* `text` <[string]> A text or a RegExp to match in element `textContent`.

```js
// Select a row containing a text
const row = await expect(page).toMatchElement('tr', { text: 'My row' })
// Click on the third column link
await expect(row).toClick('td:nth-child(2) a')
```

### <a name="toSelect"></a>expect(instance).toSelect(selector, valueOrText[, options])

* `instance` <[Page]|[ElementHandle]> Context
* `selector` <[string]> A [selector] to match select [element]
* `valueOrText` <[string]> Value or text matching option
* `options` <[Object]> Optional parameters
* `polling` <[string]|[number]> An interval at which the `pageFunction` is executed, defaults to `raf`. If `polling` is a number, then it is treated as an interval in milliseconds at which the function would be executed. If `polling` is a string, then it can be one of the following values:
* `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 `500`.

```js
await expect(page).toSelect('select[name="choices"]', 'Choice 1')
```

### expect(page).toUploadFile(selector, filePath)
### <a name="toUploadFile"></a>expect(instance).toUploadFile(selector, filePath[, options])

* `instance` <[Page]|[ElementHandle]> Context
* `selector` <[string]> A [selector] to match input [element]
* `filePath` <[string]> A file path
* `options` <[Object]> Optional parameters
* `polling` <[string]|[number]> An interval at which the `pageFunction` is executed, defaults to `raf`. If `polling` is a number, then it is treated as an interval in milliseconds at which the function would be executed. If `polling` is a string, then it can be one of the following values:
* `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 `500`.

```js
import path from 'path'
Expand Down Expand Up @@ -148,3 +206,5 @@ MIT
[element]: https://developer.mozilla.org/en-US/docs/Web/API/element 'Element'
[map]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map 'Map'
[selector]: https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors 'selector'
[page]: https://github.com/GoogleChrome/puppeteer/blob/master/docs/api.md#class-page 'Page'
[element-handle]: https://github.com/GoogleChrome/puppeteer/blob/master/docs/api.md#class-elementhandle 'ElementHandle'
62 changes: 45 additions & 17 deletions packages/expect-puppeteer/src/index.js
Original file line number Diff line number Diff line change
@@ -1,23 +1,42 @@
/* eslint-disable no-use-before-define, no-restricted-syntax, no-await-in-loop */
import toMatch from './matchers/toMatch'
import { getPuppeteerType } from './utils'
import notToMatch from './matchers/notToMatch'
import notToMatchElement from './matchers/notToMatchElement'
import toClick from './matchers/toClick'
import toSelect from './matchers/toSelect'
import toUploadFile from './matchers/toUploadFile'
import toDisplayDialog from './matchers/toDisplayDialog'
import toFill from './matchers/toFill'
import toFillForm from './matchers/toFillForm'
import toDisplayDialog from './matchers/toDisplayDialog'
import notToMatch from './matchers/notToMatch'
import toMatch from './matchers/toMatch'
import toMatchElement from './matchers/toMatchElement'
import toSelect from './matchers/toSelect'
import toUploadFile from './matchers/toUploadFile'

const matchers = {
toMatch,
const pageMatchers = {
toClick,
toDisplayDialog,
toFill,
toFillForm,
toMatch,
toMatchElement,
toSelect,
toUploadFile,
not: {
toMatch: notToMatch,
toMatchElement: notToMatchElement,
},
}

const elementHandleMatchers = {
toClick,
toFill,
toFillForm,
toDisplayDialog,
toMatch,
toMatchElement,
toSelect,
toUploadFile,
not: {
toMatch: notToMatch,
toMatchElement: notToMatchElement,
},
}

Expand All @@ -36,36 +55,45 @@ function createMatcher(matcher, page) {
}
}

function expectPage(page) {
function internalExpect(type, matchers) {
const expectation = {
not: {},
}

Object.keys(matchers).forEach(key => {
if (key === 'not') return
expectation[key] = createMatcher(matchers[key], page)
expectation[key] = createMatcher(matchers[key], type)
})

Object.keys(matchers.not).forEach(key => {
expectation.not[key] = createMatcher(matchers.not[key], page)
expectation.not[key] = createMatcher(matchers.not[key], type)
})

return expectation
}

function expectPuppeteer(actual) {
const type = getPuppeteerType(actual)
switch (type) {
case 'Page':
return internalExpect(actual, pageMatchers)
case 'ElementHandle':
return internalExpect(actual, elementHandleMatchers)
default:
throw new Error(`${actual} is not supported`)
}
}

if (typeof global.expect !== 'undefined') {
const isPuppeteerPage = object =>
Boolean(object && object.$ && object.$$ && object.close && object.click)
const originalExpect = global.expect
global.expect = (actual, ...args) => {
if (isPuppeteerPage(actual)) {
return expectPage(actual)
}
const type = getPuppeteerType(actual)
if (type) return expectPuppeteer(actual)
return originalExpect(actual, ...args)
}
Object.keys(originalExpect).forEach(prop => {
global.expect[prop] = originalExpect[prop]
})
}

module.exports = expectPage
module.exports = expectPuppeteer
15 changes: 13 additions & 2 deletions packages/expect-puppeteer/src/matchers/notToMatch.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,19 @@
async function notToMatch(page, matcher, options = { timeout: 500 }) {
import { defaultOptions, getContext } from '../utils'

async function notToMatch(instance, matcher, options) {
options = defaultOptions(options)

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

try {
await page.waitForFunction(
`document.body && document.body.textContent.match(new RegExp('${matcher}')) === null`,
(handle, matcher) => {
if (!handle) return false
return handle.textContent.match(new RegExp(matcher)) === null
},
options,
handle,
matcher,
)
} catch (error) {
throw new Error(`Text found "${matcher}"`)
Expand Down
38 changes: 29 additions & 9 deletions packages/expect-puppeteer/src/matchers/notToMatch.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,37 @@ describe('not.toMatch', () => {
await page.goto('http://localhost:4444')
})

it('should be ok if text is not in the page', async () => {
await expect(page).not.toMatch('Nop!')
describe('Page', () => {
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(2)

try {
await expect(page).not.toMatch('home')
} catch (error) {
expect(error.message).toMatch('Text found "home"')
}
})
})

it('should return an error if text is in the page', async () => {
expect.assertions(2)
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(2)
const dialogBtn = await page.$('#dialog-btn')

try {
await expect(page).not.toMatch('home')
} catch (error) {
expect(error.message).toMatch('Text found "home"')
}
try {
await expect(dialogBtn).not.toMatch('Open dialog')
} catch (error) {
expect(error.message).toMatch('Text found "Open dialog"')
}
})
})
})
38 changes: 38 additions & 0 deletions packages/expect-puppeteer/src/matchers/notToMatchElement.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { defaultOptions, getContext } from '../utils'

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

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

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,
text,
)
} catch (error) {
throw new Error(
`Element ${selector}${
text !== undefined ? ` (text: "${text}") ` : ' '
}found`,
)
}
}

export default notToMatchElement
Loading

0 comments on commit 4d37d5b

Please sign in to comment.