Skip to content

Commit 81e6bc3

Browse files
committed
feat(fixture): support chaining locator queries with locator.within()
// Synchronous ```ts test('chaining synchronous queries', async ({screen}) => { const locator = screen.getByRole('figure').within().getByText('Some image') expect(await locator.textContent()).toEqual('Some image') }) ``` // Synchronous + Asynchronous ```ts test('chaining multiple asynchronous queries between synchronous queries', async ({screen}) => { const locator = await screen .getByTestId('modal-container') .within() .findByRole('dialog') .within() .findByRole('alert') .within() .getByRole('button', {name: 'Close'}) expect(await locator.textContent()).toEqual('Close') }) ```
1 parent b44dbe0 commit 81e6bc3

File tree

7 files changed

+374
-76
lines changed

7 files changed

+374
-76
lines changed

lib/fixture/locator/fixtures.ts

+6-5
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
import type {Locator, PlaywrightTestArgs, TestFixture} from '@playwright/test'
2-
import {Page, selectors} from '@playwright/test'
1+
import type {PlaywrightTestArgs, TestFixture} from '@playwright/test'
2+
import {selectors} from '@playwright/test'
33

44
import type {TestingLibraryDeserializedFunction as DeserializedFunction} from '../helpers'
55
import type {
66
Config,
77
LocatorQueries as Queries,
8+
QueryRoot,
89
Screen,
910
SelectorEngine,
1011
SynchronousQuery,
@@ -47,10 +48,10 @@ const withinFixture: TestFixture<Within, TestArguments> = async (
4748
{asyncUtilExpectedState, asyncUtilTimeout},
4849
use,
4950
) =>
50-
use(<Root extends Page | Locator>(root: Root) =>
51+
use(<Root extends QueryRoot>(root: Root) =>
5152
'goto' in root
52-
? screenFor(root, {asyncUtilExpectedState, asyncUtilTimeout}).proxy
53-
: (queriesFor(root, {asyncUtilExpectedState, asyncUtilTimeout}) as WithinReturn<Root>),
53+
? (screenFor(root, {asyncUtilExpectedState, asyncUtilTimeout}).proxy as WithinReturn<Root>)
54+
: (queriesFor<Root>(root, {asyncUtilExpectedState, asyncUtilTimeout}) as WithinReturn<Root>),
5455
)
5556

5657
type SynchronousQueryParameters = Parameters<Queries[SynchronousQuery]>

lib/fixture/locator/index.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
export type {Queries} from './fixtures'
2+
export type {LocatorPromise} from './queries'
3+
14
export {
25
installTestingLibraryFixture,
36
options,
@@ -6,5 +9,4 @@ export {
69
screenFixture,
710
withinFixture,
811
} from './fixtures'
9-
export type {Queries} from './fixtures'
1012
export {queriesFor} from './queries'

lib/fixture/locator/queries.ts

+132-59
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import type {Locator, Page} from '@playwright/test'
2-
import {errors} from '@playwright/test'
1+
import type {Page} from '@playwright/test'
2+
import {Locator, errors} from '@playwright/test'
33
import {queries} from '@testing-library/dom'
44

55
import {replacer} from '../helpers'
@@ -9,14 +9,19 @@ import type {
99
FindQuery,
1010
GetQuery,
1111
LocatorQueries as Queries,
12+
QueriesReturn,
1213
Query,
1314
QueryQuery,
15+
QueryRoot,
1416
Screen,
1517
SynchronousQuery,
18+
TestingLibraryLocator,
1619
} from '../types'
1720

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

23+
type SynchronousQueryParameters = Parameters<Queries[SynchronousQuery]>
24+
2025
const isAllQuery = (query: Query): query is AllQuery => query.includes('All')
2126

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

32-
const createFindQuery =
33-
(
34-
pageOrLocator: Page | Locator,
35-
query: FindQuery,
36-
{asyncUtilTimeout, asyncUtilExpectedState}: Partial<Config> = {},
37-
) =>
38-
async (...[id, options, waitForElementOptions]: Parameters<Queries[FindQuery]>) => {
39-
const synchronousOptions = ([id, options] as const).filter(Boolean)
40-
41-
const locator = pageOrLocator.locator(
42-
`${queryToSelector(findQueryToQueryQuery(query))}=${JSON.stringify(
43-
synchronousOptions,
44-
replacer,
45-
)}`,
46-
)
47-
48-
const {state: expectedState = asyncUtilExpectedState, timeout = asyncUtilTimeout} =
49-
waitForElementOptions ?? {}
50-
51-
try {
52-
await locator.first().waitFor({state: expectedState, timeout})
53-
} catch (error) {
54-
// In the case of a `waitFor` timeout from Playwright, we want to
55-
// surface the appropriate error from Testing Library, so run the
56-
// query one more time as `get*` knowing that it will fail with the
57-
// error that we want the user to see instead of the `TimeoutError`
58-
if (error instanceof errors.TimeoutError) {
59-
const timeoutLocator = pageOrLocator
60-
.locator(
61-
`${queryToSelector(findQueryToGetQuery(query))}=${JSON.stringify(
62-
synchronousOptions,
63-
replacer,
64-
)}`,
65-
)
66-
.first()
67-
68-
// Handle case where element is attached, but hidden, and the expected
69-
// state is set to `visible`. In this case, dereferencing the
70-
// `Locator` instance won't throw a `get*` query error, so just
71-
// surface the original Playwright timeout error
72-
if (expectedState === 'visible' && !(await timeoutLocator.isVisible())) {
73-
throw error
74-
}
37+
class LocatorPromise extends Promise<Locator> {
38+
/**
39+
* Wrap an `async` function `Promise` return value in a `LocatorPromise`.
40+
* This allows us to use `async/await` and still return a custom
41+
* `LocatorPromise` instance instead of `Promise`.
42+
*
43+
* @param fn
44+
* @returns
45+
*/
46+
static wrap<A extends any[]>(fn: (...args: A) => Promise<Locator>, config: Partial<Config>) {
47+
return (...args: A) => LocatorPromise.from(fn(...args), config)
48+
}
7549

76-
// In all other cases, dereferencing the `Locator` instance here should
77-
// cause the above `get*` query to throw an error in Testing Library
78-
return timeoutLocator.waitFor({state: expectedState, timeout})
79-
}
50+
static from(promise: Promise<Locator>, config: Partial<Config>) {
51+
return new LocatorPromise((resolve, reject) => {
52+
promise.then(resolve).catch(reject)
53+
}, config)
54+
}
55+
56+
config: Partial<Config>
8057

81-
throw error
82-
}
58+
constructor(
59+
executor: (
60+
resolve: (value: Locator | PromiseLike<Locator>) => void,
61+
reject: (reason?: any) => void,
62+
) => void,
63+
config: Partial<Config>,
64+
) {
65+
super(executor)
66+
67+
this.config = config
68+
}
8369

84-
return locator
70+
within() {
71+
// eslint-disable-next-line @typescript-eslint/no-use-before-define
72+
return queriesFor(this, this.config)
8573
}
74+
}
75+
76+
const locatorFor = (
77+
root: Exclude<QueryRoot, Promise<any>>,
78+
query: SynchronousQuery,
79+
options: SynchronousQueryParameters,
80+
) => root.locator(`${queryToSelector(query)}=${JSON.stringify(options, replacer)}`)
81+
82+
const augmentedLocatorFor = (
83+
root: Exclude<QueryRoot, Promise<any>>,
84+
query: SynchronousQuery,
85+
options: SynchronousQueryParameters,
86+
config: Partial<Config>,
87+
) => {
88+
const locator = locatorFor(root, query, options)
89+
90+
return new Proxy(locator, {
91+
get(target, property, receiver) {
92+
return property === 'within'
93+
? // eslint-disable-next-line @typescript-eslint/no-use-before-define
94+
() => queriesFor(target, config)
95+
: Reflect.get(target, property, receiver)
96+
},
97+
}) as TestingLibraryLocator
98+
}
99+
100+
const createFindQuery = (
101+
root: QueryRoot,
102+
query: FindQuery,
103+
{asyncUtilTimeout, asyncUtilExpectedState}: Partial<Config> = {},
104+
) =>
105+
LocatorPromise.wrap(
106+
async (...[id, options, waitForElementOptions]: Parameters<Queries[FindQuery]>) => {
107+
const settledRoot = root instanceof LocatorPromise ? await root : root
108+
const synchronousOptions = (options ? [id, options] : [id]) as SynchronousQueryParameters
109+
110+
const locator = locatorFor(settledRoot, findQueryToQueryQuery(query), synchronousOptions)
111+
const {state: expectedState = asyncUtilExpectedState, timeout = asyncUtilTimeout} =
112+
waitForElementOptions ?? {}
113+
114+
try {
115+
await locator.first().waitFor({state: expectedState, timeout})
116+
} catch (error) {
117+
// In the case of a `waitFor` timeout from Playwright, we want to
118+
// surface the appropriate error from Testing Library, so run the
119+
// query one more time as `get*` knowing that it will fail with the
120+
// error that we want the user to see instead of the `TimeoutError`
121+
if (error instanceof errors.TimeoutError) {
122+
const timeoutLocator = locatorFor(
123+
settledRoot,
124+
findQueryToGetQuery(query),
125+
synchronousOptions,
126+
).first()
127+
128+
// Handle case where element is attached, but hidden, and the expected
129+
// state is set to `visible`. In this case, dereferencing the
130+
// `Locator` instance won't throw a `get*` query error, so just
131+
// surface the original Playwright timeout error
132+
if (expectedState === 'visible' && !(await timeoutLocator.isVisible())) {
133+
throw error
134+
}
135+
136+
// In all other cases, dereferencing the `Locator` instance here should
137+
// cause the above `get*` query to throw an error in Testing Library
138+
await timeoutLocator.waitFor({state: expectedState, timeout})
139+
}
140+
}
141+
142+
return locator
143+
},
144+
{asyncUtilExpectedState, asyncUtilTimeout},
145+
)
86146

87147
/**
88148
* Given a `Page` or `Locator` instance, return an object of Testing Library
@@ -93,21 +153,26 @@ const createFindQuery =
93153
* should use the `locatorFixtures` with **@playwright/test** instead.
94154
* @see {@link locatorFixtures}
95155
*
96-
* @param pageOrLocator `Page` or `Locator` instance to use as the query root
156+
* @param root `Page` or `Locator` instance to use as the query root
97157
* @param config Testing Library configuration to apply to queries
98158
*
99159
* @returns object containing scoped Testing Library query methods
100160
*/
101-
const queriesFor = (pageOrLocator: Page | Locator, config?: Partial<Config>) =>
161+
const queriesFor = <Root extends QueryRoot>(
162+
root: Root,
163+
config: Partial<Config>,
164+
): QueriesReturn<Root> =>
102165
allQueryNames.reduce(
103166
(rest, query) => ({
104167
...rest,
105168
[query]: isFindQuery(query)
106-
? createFindQuery(pageOrLocator, query, config)
107-
: (...args: Parameters<Queries[SynchronousQuery]>) =>
108-
pageOrLocator.locator(`${queryToSelector(query)}=${JSON.stringify(args, replacer)}`),
169+
? createFindQuery(root, query, config)
170+
: (...options: SynchronousQueryParameters) =>
171+
root instanceof LocatorPromise
172+
? root.then(r => locatorFor(r, query, options))
173+
: augmentedLocatorFor(root, query, options, config),
109174
}),
110-
{} as Queries,
175+
{} as QueriesReturn<Root>,
111176
)
112177

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

122-
export {allQueryNames, isAllQuery, isNotFindQuery, queriesFor, screenFor, synchronousQueryNames}
187+
export {
188+
LocatorPromise,
189+
allQueryNames,
190+
isAllQuery,
191+
isNotFindQuery,
192+
queriesFor,
193+
screenFor,
194+
synchronousQueryNames,
195+
}

lib/fixture/types.ts

+28-11
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {queries} from '@testing-library/dom'
55
import type {Config as CommonConfig} from '../common'
66

77
import {reviver} from './helpers'
8+
import type {LocatorPromise} from './locator'
89

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

26+
type KebabCase<S> = S extends `${infer C}${infer T}`
27+
? T extends Uncapitalize<T>
28+
? `${Uncapitalize<C>}${KebabCase<T>}`
29+
: `${Uncapitalize<C>}-${KebabCase<T>}`
30+
: S
31+
2532
type Queries = typeof queries
2633
type WaitForState = Exclude<Parameters<Locator['waitFor']>[0], undefined>['state']
2734
type AsyncUtilExpectedState = Extract<WaitForState, 'visible' | 'attached'>
2835

36+
export type TestingLibraryLocator = Locator & {within: () => LocatorQueries}
37+
2938
type ConvertQuery<Query extends Queries[keyof Queries]> = Query extends (
3039
el: HTMLElement,
3140
...rest: infer Rest
3241
) => HTMLElement | (HTMLElement[] | null) | (HTMLElement | null)
33-
? (...args: Rest) => Locator
42+
? (...args: Rest) => TestingLibraryLocator
3443
: Query extends (
3544
el: HTMLElement,
3645
id: infer Id,
@@ -41,23 +50,31 @@ type ConvertQuery<Query extends Queries[keyof Queries]> = Query extends (
4150
id: Id,
4251
options?: Options,
4352
waitForOptions?: WaitForOptions & {state?: AsyncUtilExpectedState},
44-
) => Promise<Locator>
53+
) => LocatorPromise
4554
: never
4655

47-
type KebabCase<S> = S extends `${infer C}${infer T}`
48-
? T extends Uncapitalize<T>
49-
? `${Uncapitalize<C>}${KebabCase<T>}`
50-
: `${Uncapitalize<C>}-${KebabCase<T>}`
51-
: S
52-
5356
export type LocatorQueries = {[K in keyof Queries]: ConvertQuery<Queries[K]>}
5457

55-
export type WithinReturn<Root extends Locator | Page> = Root extends Page ? Screen : LocatorQueries
58+
type ConvertQueryDeferred<Query extends LocatorQueries[keyof LocatorQueries]> = Query extends (
59+
...rest: infer Rest
60+
) => any
61+
? (...args: Rest) => LocatorPromise
62+
: never
63+
64+
export type DeferredLocatorQueries = {
65+
[K in keyof LocatorQueries]: ConvertQueryDeferred<LocatorQueries[K]>
66+
}
67+
68+
export type WithinReturn<Root extends QueryRoot> = Root extends Page ? Screen : QueriesReturn<Root>
69+
export type QueriesReturn<Root extends QueryRoot> = Root extends LocatorPromise
70+
? DeferredLocatorQueries
71+
: LocatorQueries
72+
73+
export type QueryRoot = Page | Locator | LocatorPromise
5674
export type Screen = LocatorQueries & Page
57-
export type Within = <Root extends Locator | Page>(locator: Root) => WithinReturn<Root>
75+
export type Within = <Root extends QueryRoot>(locator: Root) => WithinReturn<Root>
5876

5977
export type Query = keyof Queries
60-
6178
export type AllQuery = Extract<Query, `${string}All${string}`>
6279
export type FindQuery = Extract<Query, `find${string}`>
6380
export type GetQuery = Extract<Query, `get${string}`>

playwright.config.ts

+1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ const config: PlaywrightTestConfig = {
44
reporter: 'list',
55
testDir: 'test/fixture',
66
use: {actionTimeout: 3000},
7+
timeout: 5 * 1000,
78
}
89

910
export default config

0 commit comments

Comments
 (0)