Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings
This repository was archived by the owner on Aug 6, 2025. It is now read-only.

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

Lines changed: 6 additions & 5 deletions
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}).proxyasWithinReturn<Root>)
54+
: (queriesFor<Root>(root, {asyncUtilExpectedState, asyncUtilTimeout}) as WithinReturn<Root>),
5455
)
5556

5657
type SynchronousQueryParameters = Parameters<Queries[SynchronousQuery]>

‎lib/fixture/locator/index.ts

Lines changed: 3 additions & 1 deletion
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

Lines changed: 132 additions & 59 deletions
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

Lines changed: 28 additions & 11 deletions
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

Lines changed: 1 addition & 0 deletions
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)

AltStyle によって変換されたページ (->オリジナル) /