Skip to content

Commit

Permalink
feat: support chaining queries
Browse files Browse the repository at this point in the history
As of WebdriverIO v7.19.0 it is possible to chain custom queries as long
as they end in `$`. See the release notes for v7.19.0 here:
https://github.com/webdriverio/webdriverio/blob/v7.19.0/CHANGELOG.md

Add chainable queries to the `Browser` and `Element`
as commands of the form `{queryName}$`, for example the chainable
version of `getByText` is `getByText$`.

Infer the types for `ChainablePromiseElement` and
`ChainablePromiseArray` from the `Browser` and `Element` types, that way
we don't need to lock in a specific version of WebdriverIO's types.

To add the types of the chainable commands to the global WebdriverIO
types users now need to add `WebdriverIOQueriesChainable` to the global
`Browser`, `Element` and `ChainablePromiseElement` interfaces.

This should not break existing behaviour or types, and the chainable
custom commands should behave the same as the existing commands if the
WebdriverIO version is less than v7.19.0.

Typescript users need to use at least v4.1 as the types now make use of
template literal strings to modify the query name to include `$` at the
end.

Bump development version of WebdriverIO packages to at least v7.19.0 and
add tests for chaining queries.
  • Loading branch information
olivierwilkinson committed Oct 27, 2022
1 parent 09183e8 commit 8274749
Show file tree
Hide file tree
Showing 6 changed files with 145 additions and 50 deletions.
12 changes: 6 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,12 @@
"@types/simmerjs": "^0.5.1",
"@typescript-eslint/eslint-plugin": "^4.14.0",
"@typescript-eslint/parser": "^4.14.0",
"@wdio/cli": "^7.12.1",
"@wdio/local-runner": "^7.12.1",
"@wdio/mocha-framework": "^7.12.0",
"@wdio/selenium-standalone-service": "^7.10.1",
"@wdio/spec-reporter": "^7.10.1",
"@wdio/sync": "^7.12.1",
"@wdio/cli": "^7.19.0",
"@wdio/local-runner": "^7.19.0",
"@wdio/mocha-framework": "^7.19.0",
"@wdio/selenium-standalone-service": "^7.19.0",
"@wdio/spec-reporter": "^7.19.0",
"@wdio/sync": "^7.19.0",
"eslint": "^7.6.0",
"kcd-scripts": "^11.1.0",
"npm-run-all": "^4.1.5",
Expand Down
21 changes: 15 additions & 6 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
Config,
QueryName,
WebdriverIOQueries,
WebdriverIOQueriesChainable,
ObjectQueryArg,
SerializedObject,
SerializedArg,
Expand Down Expand Up @@ -224,8 +225,8 @@ eslint-disable
@typescript-eslint/no-explicit-any,
@typescript-eslint/no-unsafe-argument
*/
function setupBrowser(browser: BrowserBase): WebdriverIOQueries {
const queries: {[key: string]: WebdriverIOQueries[QueryName]} = {}
function setupBrowser<Browser extends BrowserBase>(browser: Browser): WebdriverIOQueries {
const queries: {[key: string | number | symbol]: WebdriverIOQueries[QueryName]} = {}

Object.keys(baseQueries).forEach((key) => {
const queryName = key as QueryName
Expand All @@ -240,20 +241,28 @@ function setupBrowser(browser: BrowserBase): WebdriverIOQueries {
// add query to response queries
queries[queryName] = query as WebdriverIOQueries[QueryName]

// add query to BrowserObject
// add query to BrowserObject and Elements
browser.addCommand(queryName, query as WebdriverIOQueries[QueryName])

// add query to Elements
browser.addCommand(
queryName,
function (this, ...args) {
return within(this)[queryName](...args)
},
true,
)

// add chainable query to BrowserObject and Elements
browser.addCommand(`${queryName}$`, query as WebdriverIOQueriesChainable<Browser>[`${QueryName}$`])
browser.addCommand(
`${queryName}$`,
function (this, ...args) {
return within(this)[queryName](...args)
},
true,
)
})

return queries as WebdriverIOQueries
return queries as unknown as WebdriverIOQueries
}
/*
eslint-enable
Expand Down
85 changes: 54 additions & 31 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ import {
SelectorMatcherOptions,
MatcherOptions,
} from '@testing-library/dom'
import {SelectorsBase} from './wdio-types'

export type Queries = typeof queries
export type QueryName = keyof Queries

export type Config = Pick<
BaseConfig,
Expand All @@ -16,53 +20,72 @@ export type Config = Pick<
| 'throwSuggestions'
>

export type WebdriverIOQueryReturnType<T> = T extends Promise<HTMLElement>
? WebdriverIO.Element
: T extends HTMLElement
? WebdriverIO.Element
: T extends Promise<HTMLElement[]>
? WebdriverIO.Element[]
: T extends HTMLElement[]
? WebdriverIO.Element[]
: T extends null
? null
: never
export type WebdriverIOQueryReturnType<Element, ElementArray, T> =
T extends Promise<HTMLElement>
? Element
: T extends HTMLElement
? Element
: T extends Promise<HTMLElement[]>
? ElementArray
: T extends HTMLElement[]
? ElementArray
: T extends null
? null
: never

export type WebdriverIOBoundFunction<T> = (
export type WebdriverIOBoundFunction<Element, ElementArray, T> = (
...params: Parameters<BoundFunctionBase<T>>
) => Promise<WebdriverIOQueryReturnType<ReturnType<BoundFunctionBase<T>>>>
) => Promise<
WebdriverIOQueryReturnType<
Element,
ElementArray,
ReturnType<BoundFunctionBase<T>>
>
>

export type WebdriverIOBoundFunctionSync<T> = (
export type WebdriverIOBoundFunctionSync<Element, ElementArray, T> = (
...params: Parameters<BoundFunctionBase<T>>
) => WebdriverIOQueryReturnType<ReturnType<BoundFunctionBase<T>>>
) => WebdriverIOQueryReturnType<
Element,
ElementArray,
ReturnType<BoundFunctionBase<T>>
>

export type WebdriverIOBoundFunctions<T> = {
[P in keyof T]: WebdriverIOBoundFunction<T[P]>
export type WebdriverIOQueries = {
[P in keyof Queries]: WebdriverIOBoundFunction<
WebdriverIO.Element,
WebdriverIO.Element[],
Queries[P]
>
}

export type WebdriverIOBoundFunctionsSync<T> = {
[P in keyof T]: WebdriverIOBoundFunctionSync<T[P]>
export type WebdriverIOQueriesSync = {
[P in keyof Queries]: WebdriverIOBoundFunctionSync<
WebdriverIO.Element,
WebdriverIO.Element[],
Queries[P]
>
}

export type WebdriverIOQueries = WebdriverIOBoundFunctions<typeof queries>
export type WebdriverIOQueriesSync = WebdriverIOBoundFunctionsSync<
typeof queries
>

export type QueryName = keyof typeof queries
export type WebdriverIOQueriesChainable<
Container extends SelectorsBase | undefined,
> = {
[P in keyof Queries as `${string & P}$`]: Container extends SelectorsBase
? WebdriverIOBoundFunctionSync<
ReturnType<Container['$']>,
ReturnType<Container['$$']>,
Queries[P]
>
: undefined
}

export type ObjectQueryArg =
| MatcherOptions
| queries.ByRoleOptions
| SelectorMatcherOptions
| waitForOptions

export type QueryArg =
| ObjectQueryArg
| RegExp
| number
| string
| undefined
export type QueryArg = ObjectQueryArg | RegExp | number | string | undefined

export type SerializedObject = {
serialized: 'object'
Expand Down
18 changes: 14 additions & 4 deletions src/wdio-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,22 @@ export type $ = (
| Promise<WebdriverIO.Element>
| WebdriverIO.Element

export type $$ = (
selector: any,
) =>
| ChainablePromiseArrayBase<Promise<WebdriverIO.Element>>
| Promise<WebdriverIO.Element[]>
| WebdriverIO.Element[]

export type ChainablePromiseElementBase<T> = Promise<T> & {$: $}
export type ChainablePromiseArrayBase<T> = Promise<T>

export type ElementBase = {
export type SelectorsBase = {
$: $
$$: $$
}

export type ElementBase = SelectorsBase & {
parent: {
execute<T>(
script: string | ((...args: any[]) => T),
Expand All @@ -38,9 +50,7 @@ export type ElementBase = {
}
}

export type BrowserBase = {
$: $

export type BrowserBase = SelectorsBase & {
addCommand<T extends boolean>(
queryName: string,
commandFn: (
Expand Down
43 changes: 43 additions & 0 deletions test/async/chaining.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import {setupBrowser} from '../../src'

describe('chaining', () => {
it('can chain browser getBy queries', async () => {
setupBrowser(browser)

const button = await browser
.getByTestId$('nested')
.getByText$('Button Text')

await button.click()

expect(await button.getText()).toEqual('Button Clicked')
})

it('can chain element getBy queries', async () => {
const {getByTestId} = setupBrowser(browser)

const nested = await getByTestId('nested')
await nested.getByText$('Button Text').click()

expect(await browser.getByText$('Button Clicked').getText()).toEqual(
'Button Clicked',
)
})

it('can chain browser getAllBy queries', async () => {
setupBrowser(browser)

await browser.getByTestId$('nested').getAllByText$('Button Text')[0].click()

expect(await browser.getAllByText('Button Clicked')).toHaveLength(1)
})

it('can chain element getAllBy queries', async () => {
const {getByTestId} = setupBrowser(browser)

const nested = await getByTestId('nested')
await nested.getAllByText$('Button Text')[0].click()

expect(await nested.getAllByText('Button Clicked')).toHaveLength(1)
})
})
16 changes: 13 additions & 3 deletions test/async/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,21 @@ eslint-disable
@typescript-eslint/no-empty-interface
*/

import {WebdriverIOQueries} from '../../src'
import {WebdriverIOQueriesChainable, WebdriverIOQueries} from '../../src'
import {SelectorsBase} from '../../src/wdio-types'

declare global {
namespace WebdriverIO {
interface Browser extends WebdriverIOQueries {}
interface Element extends WebdriverIOQueries {}
interface Browser
extends WebdriverIOQueries,
WebdriverIOQueriesChainable<Browser> {}
interface Element
extends WebdriverIOQueries,
WebdriverIOQueriesChainable<Element> {}
}
}

declare module 'webdriverio' {
interface ChainablePromiseElement<T extends SelectorsBase | undefined>
extends WebdriverIOQueriesChainable<T> {}
}

0 comments on commit 8274749

Please sign in to comment.