Skip to content

✨ Support chaining queries #501

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

Merged
merged 4 commits into from
Sep 28, 2022
Merged
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
40 changes: 39 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ There are currently a few different ways to use Playwright Testing Library, depe

### Playwright Test Fixture

> 🔖 Added in [**4.4.0**](https://github.com/testing-library/playwright-testing-library/releases/tag/v4.4.0)

Using the `Locator` Playwright Test (**@playwright/test**) fixture with **@playwright-testing-library/test**.

#### Setup
Expand Down Expand Up @@ -90,7 +92,7 @@ test('my form', async ({screen, within}) => {

#### Async Methods

The `findBy` queries work the same way as they do in [Testing Library core](https://testing-library.com/docs/dom-testing-library/api-async) in that they return `Promise<Locator>` and are intended to be used to defer test execution until an element appears on the page.
The `findBy` queries work the same way as they do in [Testing Library](https://testing-library.com/docs/dom-testing-library/api-async) core in that they return `Promise<Locator>` and are intended to be used to defer test execution until an element appears on the page.

```ts
test('my modal', async ({screen, within}) => {
Expand All @@ -109,6 +111,42 @@ test('my modal', async ({screen, within}) => {
})
```

#### Chaining

> 🔖 Added in [**4.5.0**](https://github.com/testing-library/playwright-testing-library/releases/tag/v4.5.0)

As an alternative to the `within(locator: Locator)` function you're familiar with from Testing Library, Playwright Testing Library also supports chaining queries together.

All synchronous queries (`get*` + `query*`) return `Locator` instances are augmented with a `.within()` method (`TestingLibraryLocator`). All asynchronous queries (`find*`) return a special `LocatorPromise` that also supports `.within()`. This makes it possible to chain queries, including chaining `get*`, `query*` and `find*` interchangeably.

> ⚠️ Note that including any `find*` query in the chain will make the entire chain asynchronous

##### Synchronous

```ts
test('chaining synchronous queries', async ({screen}) => {
const locator = screen.getByRole('figure').within().findByRole('img')

expect(await locator.getAttribute('alt')).toEqual('Some image')
})
```

##### Synchronous + Asynchronous

```ts
test('chaining synchronous queries + asynchronous queries', ({screen}) => {
// ↓↓↓↓↓ including any `find*` queries makes the whole chain asynchronous
const locator = await screen
.getByTestId('modal-container') // Get "modal container" or throw (sync)
.within()
.findByRole('dialog') // Wait for modal to appear (async, until `asyncUtilTimeout`)
.within()
.getByRole('button', {name: 'Close'}) // Get close button within modal (sync)

expect(await locator.textContent()).toEqual('Close')
})
```

#### Configuration

The `Locator` query API is configured using Playwright's `use` API. See Playwright's documentation for [global](https://playwright.dev/docs/api/class-testconfig#test-config-use), [project](https://playwright.dev/docs/api/class-testproject#test-project-use), and [test](https://playwright.dev/docs/api/class-test#test-use).
Expand Down
11 changes: 6 additions & 5 deletions lib/fixture/locator/fixtures.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import type {Locator, PlaywrightTestArgs, TestFixture} from '@playwright/test'
import {Page, selectors} from '@playwright/test'
import type {PlaywrightTestArgs, TestFixture} from '@playwright/test'
import {selectors} from '@playwright/test'

import type {TestingLibraryDeserializedFunction as DeserializedFunction} from '../helpers'
import type {
Config,
LocatorQueries as Queries,
QueryRoot,
Screen,
SelectorEngine,
SynchronousQuery,
Expand Down Expand Up @@ -47,10 +48,10 @@ const withinFixture: TestFixture<Within, TestArguments> = async (
{asyncUtilExpectedState, asyncUtilTimeout},
use,
) =>
use(<Root extends Page | Locator>(root: Root) =>
use(<Root extends QueryRoot>(root: Root) =>
'goto' in root
? screenFor(root, {asyncUtilExpectedState, asyncUtilTimeout}).proxy
: (queriesFor(root, {asyncUtilExpectedState, asyncUtilTimeout}) as WithinReturn<Root>),
? (screenFor(root, {asyncUtilExpectedState, asyncUtilTimeout}).proxy as WithinReturn<Root>)
: (queriesFor<Root>(root, {asyncUtilExpectedState, asyncUtilTimeout}) as WithinReturn<Root>),
)

type SynchronousQueryParameters = Parameters<Queries[SynchronousQuery]>
Expand Down
4 changes: 3 additions & 1 deletion lib/fixture/locator/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
export type {Queries} from './fixtures'
export type {LocatorPromise} from './queries'

export {
installTestingLibraryFixture,
options,
Expand All @@ -6,5 +9,4 @@ export {
screenFixture,
withinFixture,
} from './fixtures'
export type {Queries} from './fixtures'
export {queriesFor} from './queries'
191 changes: 132 additions & 59 deletions lib/fixture/locator/queries.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type {Locator, Page} from '@playwright/test'
import {errors} from '@playwright/test'
import type {Page} from '@playwright/test'
import {Locator, errors} from '@playwright/test'
import {queries} from '@testing-library/dom'

import {replacer} from '../helpers'
Expand All @@ -9,14 +9,19 @@ import type {
FindQuery,
GetQuery,
LocatorQueries as Queries,
QueriesReturn,
Query,
QueryQuery,
QueryRoot,
Screen,
SynchronousQuery,
TestingLibraryLocator,
} from '../types'

import {includes, queryToSelector} from './helpers'

type SynchronousQueryParameters = Parameters<Queries[SynchronousQuery]>

const isAllQuery = (query: Query): query is AllQuery => query.includes('All')

const isFindQuery = (query: Query): query is FindQuery => query.startsWith('find')
Expand All @@ -29,60 +34,115 @@ const synchronousQueryNames = allQueryNames.filter(isNotFindQuery)
const findQueryToGetQuery = (query: FindQuery) => query.replace(/^find/, 'get') as GetQuery
const findQueryToQueryQuery = (query: FindQuery) => query.replace(/^find/, 'query') as QueryQuery

const createFindQuery =
(
pageOrLocator: Page | Locator,
query: FindQuery,
{asyncUtilTimeout, asyncUtilExpectedState}: Partial<Config> = {},
) =>
async (...[id, options, waitForElementOptions]: Parameters<Queries[FindQuery]>) => {
const synchronousOptions = ([id, options] as const).filter(Boolean)

const locator = pageOrLocator.locator(
`${queryToSelector(findQueryToQueryQuery(query))}=${JSON.stringify(
synchronousOptions,
replacer,
)}`,
)

const {state: expectedState = asyncUtilExpectedState, timeout = asyncUtilTimeout} =
waitForElementOptions ?? {}

try {
await locator.first().waitFor({state: expectedState, timeout})
} catch (error) {
// In the case of a `waitFor` timeout from Playwright, we want to
// surface the appropriate error from Testing Library, so run the
// query one more time as `get*` knowing that it will fail with the
// error that we want the user to see instead of the `TimeoutError`
if (error instanceof errors.TimeoutError) {
const timeoutLocator = pageOrLocator
.locator(
`${queryToSelector(findQueryToGetQuery(query))}=${JSON.stringify(
synchronousOptions,
replacer,
)}`,
)
.first()

// Handle case where element is attached, but hidden, and the expected
// state is set to `visible`. In this case, dereferencing the
// `Locator` instance won't throw a `get*` query error, so just
// surface the original Playwright timeout error
if (expectedState === 'visible' && !(await timeoutLocator.isVisible())) {
throw error
}
class LocatorPromise extends Promise<Locator> {
/**
* Wrap an `async` function `Promise` return value in a `LocatorPromise`.
* This allows us to use `async/await` and still return a custom
* `LocatorPromise` instance instead of `Promise`.
*
* @param fn
* @returns
*/
static wrap<A extends any[]>(fn: (...args: A) => Promise<Locator>, config: Partial<Config>) {
return (...args: A) => LocatorPromise.from(fn(...args), config)
}

// In all other cases, dereferencing the `Locator` instance here should
// cause the above `get*` query to throw an error in Testing Library
return timeoutLocator.waitFor({state: expectedState, timeout})
}
static from(promise: Promise<Locator>, config: Partial<Config>) {
return new LocatorPromise((resolve, reject) => {
promise.then(resolve).catch(reject)
}, config)
}

config: Partial<Config>

throw error
}
constructor(
executor: (
resolve: (value: Locator | PromiseLike<Locator>) => void,
reject: (reason?: any) => void,
) => void,
config: Partial<Config>,
) {
super(executor)

this.config = config
}

return locator
within() {
// eslint-disable-next-line @typescript-eslint/no-use-before-define
return queriesFor(this, this.config)
}
}

const locatorFor = (
root: Exclude<QueryRoot, Promise<any>>,
query: SynchronousQuery,
options: SynchronousQueryParameters,
) => root.locator(`${queryToSelector(query)}=${JSON.stringify(options, replacer)}`)

const augmentedLocatorFor = (
root: Exclude<QueryRoot, Promise<any>>,
query: SynchronousQuery,
options: SynchronousQueryParameters,
config: Partial<Config>,
) => {
const locator = locatorFor(root, query, options)

return new Proxy(locator, {
get(target, property, receiver) {
return property === 'within'
? // eslint-disable-next-line @typescript-eslint/no-use-before-define
() => queriesFor(target, config)
: Reflect.get(target, property, receiver)
},
}) as TestingLibraryLocator
}

const createFindQuery = (
root: QueryRoot,
query: FindQuery,
{asyncUtilTimeout, asyncUtilExpectedState}: Partial<Config> = {},
) =>
LocatorPromise.wrap(
async (...[id, options, waitForElementOptions]: Parameters<Queries[FindQuery]>) => {
const settledRoot = root instanceof LocatorPromise ? await root : root
const synchronousOptions = (options ? [id, options] : [id]) as SynchronousQueryParameters

const locator = locatorFor(settledRoot, findQueryToQueryQuery(query), synchronousOptions)
const {state: expectedState = asyncUtilExpectedState, timeout = asyncUtilTimeout} =
waitForElementOptions ?? {}

try {
await locator.first().waitFor({state: expectedState, timeout})
} catch (error) {
// In the case of a `waitFor` timeout from Playwright, we want to
// surface the appropriate error from Testing Library, so run the
// query one more time as `get*` knowing that it will fail with the
// error that we want the user to see instead of the `TimeoutError`
if (error instanceof errors.TimeoutError) {
const timeoutLocator = locatorFor(
settledRoot,
findQueryToGetQuery(query),
synchronousOptions,
).first()

// Handle case where element is attached, but hidden, and the expected
// state is set to `visible`. In this case, dereferencing the
// `Locator` instance won't throw a `get*` query error, so just
// surface the original Playwright timeout error
if (expectedState === 'visible' && !(await timeoutLocator.isVisible())) {
throw error
}

// In all other cases, dereferencing the `Locator` instance here should
// cause the above `get*` query to throw an error in Testing Library
await timeoutLocator.waitFor({state: expectedState, timeout})
}
}

return locator
},
{asyncUtilExpectedState, asyncUtilTimeout},
)

/**
* Given a `Page` or `Locator` instance, return an object of Testing Library
Expand All @@ -93,21 +153,26 @@ const createFindQuery =
* should use the `locatorFixtures` with **@playwright/test** instead.
* @see {@link locatorFixtures}
*
* @param pageOrLocator `Page` or `Locator` instance to use as the query root
* @param root `Page` or `Locator` instance to use as the query root
* @param config Testing Library configuration to apply to queries
*
* @returns object containing scoped Testing Library query methods
*/
const queriesFor = (pageOrLocator: Page | Locator, config?: Partial<Config>) =>
const queriesFor = <Root extends QueryRoot>(
root: Root,
config: Partial<Config>,
): QueriesReturn<Root> =>
allQueryNames.reduce(
(rest, query) => ({
...rest,
[query]: isFindQuery(query)
? createFindQuery(pageOrLocator, query, config)
: (...args: Parameters<Queries[SynchronousQuery]>) =>
pageOrLocator.locator(`${queryToSelector(query)}=${JSON.stringify(args, replacer)}`),
? createFindQuery(root, query, config)
: (...options: SynchronousQueryParameters) =>
root instanceof LocatorPromise
? root.then(r => locatorFor(r, query, options))
: augmentedLocatorFor(root, query, options, config),
}),
{} as Queries,
{} as QueriesReturn<Root>,
)

const screenFor = (page: Page, config: Partial<Config>) =>
Expand All @@ -119,4 +184,12 @@ const screenFor = (page: Page, config: Partial<Config>) =>
},
}) as {proxy: Screen; revoke: () => void}

export {allQueryNames, isAllQuery, isNotFindQuery, queriesFor, screenFor, synchronousQueryNames}
export {
LocatorPromise,
allQueryNames,
isAllQuery,
isNotFindQuery,
queriesFor,
screenFor,
synchronousQueryNames,
}
Loading