Skip to content

Commit

Permalink
wip: feat(fixture): support query chaining locator queries
Browse files Browse the repository at this point in the history
  • Loading branch information
jrolfs committed Sep 14, 2022
1 parent 77960b2 commit b273764
Show file tree
Hide file tree
Showing 6 changed files with 244 additions and 77 deletions.
11 changes: 6 additions & 5 deletions lib/fixture/locator/fixtures.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
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 {
Config,
LocatorQueries as Queries,
QueryRoot,
Screen,
SelectorEngine,
SynchronousQuery,
Expand Down Expand Up @@ -46,10 +47,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>),
)

declare const queryName: 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'
173 changes: 113 additions & 60 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,95 @@ 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
}

// 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})
}
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>) {
return (...args: A) => LocatorPromise.from(fn(...args))
}

throw error
}
static from(promise: Promise<Locator>) {
return new LocatorPromise((resolve, reject) => {
promise.then(resolve).catch(reject)
})
}

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

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

const augmentedLocatorFor = (...args: Parameters<typeof locatorFor>) => {
const locator = locatorFor(...args)

return new Proxy(locator, {
get(target, property, receiver) {
return property === 'within'
? // eslint-disable-next-line @typescript-eslint/no-use-before-define
() => queriesFor(target)
: 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
},
)

/**
* Given a `Page` or `Locator` instance, return an object of Testing Library
Expand All @@ -93,21 +133,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),
}),
{} as Queries,
{} as QueriesReturn<Root>,
)

const screenFor = (page: Page, config: Partial<Config>) =>
Expand All @@ -119,4 +164,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,
}
39 changes: 28 additions & 11 deletions lib/fixture/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {queries} from '@testing-library/dom'
import type {Config as CommonConfig} from '../common'

import {reviver} from './helpers'
import type {LocatorPromise} from './locator'

/**
* This type was copied across from Playwright
Expand All @@ -22,15 +23,23 @@ export type SelectorEngine = {
queryAll(root: HTMLElement, selector: string): HTMLElement[]
}

type KebabCase<S> = S extends `${infer C}${infer T}`
? T extends Uncapitalize<T>
? `${Uncapitalize<C>}${KebabCase<T>}`
: `${Uncapitalize<C>}-${KebabCase<T>}`
: S

type Queries = typeof queries
type WaitForState = Exclude<Parameters<Locator['waitFor']>[0], undefined>['state']
type AsyncUtilExpectedState = Extract<WaitForState, 'visible' | 'attached'>

export type TestingLibraryLocator = Locator & {within: () => LocatorQueries}

type ConvertQuery<Query extends Queries[keyof Queries]> = Query extends (
el: HTMLElement,
...rest: infer Rest
) => HTMLElement | (HTMLElement[] | null) | (HTMLElement | null)
? (...args: Rest) => Locator
? (...args: Rest) => TestingLibraryLocator
: Query extends (
el: HTMLElement,
id: infer Id,
Expand All @@ -41,23 +50,31 @@ type ConvertQuery<Query extends Queries[keyof Queries]> = Query extends (
id: Id,
options?: Options,
waitForOptions?: WaitForOptions & {state?: AsyncUtilExpectedState},
) => Promise<Locator>
) => LocatorPromise
: never

type KebabCase<S> = S extends `${infer C}${infer T}`
? T extends Uncapitalize<T>
? `${Uncapitalize<C>}${KebabCase<T>}`
: `${Uncapitalize<C>}-${KebabCase<T>}`
: S

export type LocatorQueries = {[K in keyof Queries]: ConvertQuery<Queries[K]>}

export type WithinReturn<Root extends Locator | Page> = Root extends Page ? Screen : LocatorQueries
type ConvertQueryDeferred<Query extends LocatorQueries[keyof LocatorQueries]> = Query extends (
...rest: infer Rest
) => any
? (...args: Rest) => LocatorPromise
: never

export type DeferredLocatorQueries = {
[K in keyof LocatorQueries]: ConvertQueryDeferred<LocatorQueries[K]>
}

export type WithinReturn<Root extends QueryRoot> = Root extends Page ? Screen : QueriesReturn<Root>
export type QueriesReturn<Root extends QueryRoot> = Root extends LocatorPromise
? DeferredLocatorQueries
: LocatorQueries

export type QueryRoot = Page | Locator | LocatorPromise
export type Screen = LocatorQueries & Page
export type Within = <Root extends Locator | Page>(locator: Root) => WithinReturn<Root>
export type Within = <Root extends QueryRoot>(locator: Root) => WithinReturn<Root>

export type Query = keyof Queries

export type AllQuery = Extract<Query, `${string}All${string}`>
export type FindQuery = Extract<Query, `find${string}`>
export type GetQuery = Extract<Query, `get${string}`>
Expand Down
55 changes: 55 additions & 0 deletions test/fixture/locators.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,4 +346,59 @@ test.describe('lib/fixture.ts (locators)', () => {
})
})
})

test.describe('query chaining', () => {
test.use({asyncUtilTimeout: 3000})

test.beforeEach(async ({page}) => {
await page.goto(`file://${path.join(__dirname, '../fixtures/chaining.html')}`)
})

test.afterEach(async ({page}) => page.close())

test('chaining synchronous queries', async ({screen}) => {
const locator = screen.getByRole('figure').within().getByText('Some image')

expect(await locator.textContent()).toEqual('Some image')
})

test('chaining an asynchronous query onto a synchronous query', async ({screen}) => {
const locator = await screen.getByRole('figure').within().findByRole('img')

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

test('chaining a synchronous query onto an asynchronous query', async ({screen}) => {
const locator = await screen.findByRole('dialog').within().getByRole('textbox')

expect(await locator.getAttribute('type')).toEqual('text')
})

test('chaining multiple synchronous queries onto asynchronous query', async ({screen}) => {
const locator = await screen
.findByRole('dialog')
.within()
.getByTestId('image-container')
.within()
.getByRole('img')

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

test.describe('configuring chained queries', () => {
test.use({
testIdAttribute: 'data-customid',
asyncUtilTimeout: 1000,
actionTimeout: 2000,
})

test('chained queries inhereit `asyncUtilTimeout`', async ({}) => {})
})

// test('chaining an asynchronous query onto a synchronous query', async ({screen}) => {
// const locator = await screen.getByRole('figure').within().findByRole('img')

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

0 comments on commit b273764

Please sign in to comment.