diff --git a/lib/fixture/locator/fixtures.ts b/lib/fixture/locator/fixtures.ts index a98f410..2a57ebc 100644 --- a/lib/fixture/locator/fixtures.ts +++ b/lib/fixture/locator/fixtures.ts @@ -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, @@ -47,10 +48,10 @@ const withinFixture: TestFixture = async ( {asyncUtilExpectedState, asyncUtilTimeout}, use, ) => - use((root: Root) => + use((root: Root) => 'goto' in root - ? screenFor(root, {asyncUtilExpectedState, asyncUtilTimeout}).proxy - : (queriesFor(root, {asyncUtilExpectedState, asyncUtilTimeout}) as WithinReturn), + ? (screenFor(root, {asyncUtilExpectedState, asyncUtilTimeout}).proxy as WithinReturn) + : (queriesFor(root, {asyncUtilExpectedState, asyncUtilTimeout}) as WithinReturn), ) type SynchronousQueryParameters = Parameters diff --git a/lib/fixture/locator/index.ts b/lib/fixture/locator/index.ts index c07e4ac..9b380bb 100644 --- a/lib/fixture/locator/index.ts +++ b/lib/fixture/locator/index.ts @@ -1,3 +1,6 @@ +export type {Queries} from './fixtures' +export type {LocatorPromise} from './queries' + export { installTestingLibraryFixture, options, @@ -6,5 +9,4 @@ export { screenFixture, withinFixture, } from './fixtures' -export type {Queries} from './fixtures' export {queriesFor} from './queries' diff --git a/lib/fixture/locator/queries.ts b/lib/fixture/locator/queries.ts index a2fbeaf..1157504 100644 --- a/lib/fixture/locator/queries.ts +++ b/lib/fixture/locator/queries.ts @@ -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' @@ -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 + const isAllQuery = (query: Query): query is AllQuery => query.includes('All') const isFindQuery = (query: Query): query is FindQuery => query.startsWith('find') @@ -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 = {}, - ) => - async (...[id, options, waitForElementOptions]: Parameters) => { - 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 { + /** + * 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(fn: (...args: A) => Promise, config: Partial) { + 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, config: Partial) { + return new LocatorPromise((resolve, reject) => { + promise.then(resolve).catch(reject) + }, config) + } + + config: Partial - throw error - } + constructor( + executor: ( + resolve: (value: Locator | PromiseLike) => void, + reject: (reason?: any) => void, + ) => void, + config: Partial, + ) { + 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>, + query: SynchronousQuery, + options: SynchronousQueryParameters, +) => root.locator(`${queryToSelector(query)}=${JSON.stringify(options, replacer)}`) + +const augmentedLocatorFor = ( + root: Exclude>, + query: SynchronousQuery, + options: SynchronousQueryParameters, + config: Partial, +) => { + 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 = {}, +) => + LocatorPromise.wrap( + async (...[id, options, waitForElementOptions]: Parameters) => { + 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 @@ -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) => +const queriesFor = ( + root: Root, + config: Partial, +): QueriesReturn => allQueryNames.reduce( (rest, query) => ({ ...rest, [query]: isFindQuery(query) - ? createFindQuery(pageOrLocator, query, config) - : (...args: Parameters) => - 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, ) const screenFor = (page: Page, config: Partial) => @@ -119,4 +184,12 @@ const screenFor = (page: Page, config: Partial) => }, }) as {proxy: Screen; revoke: () => void} -export {allQueryNames, isAllQuery, isNotFindQuery, queriesFor, screenFor, synchronousQueryNames} +export { + LocatorPromise, + allQueryNames, + isAllQuery, + isNotFindQuery, + queriesFor, + screenFor, + synchronousQueryNames, +} diff --git a/lib/fixture/types.ts b/lib/fixture/types.ts index c367e4b..cfdbd58 100644 --- a/lib/fixture/types.ts +++ b/lib/fixture/types.ts @@ -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 @@ -22,15 +23,23 @@ export type SelectorEngine = { queryAll(root: HTMLElement, selector: string): HTMLElement[] } +type KebabCase = S extends `${infer C}${infer T}` + ? T extends Uncapitalize + ? `${Uncapitalize}${KebabCase}` + : `${Uncapitalize}-${KebabCase}` + : S + type Queries = typeof queries type WaitForState = Exclude[0], undefined>['state'] type AsyncUtilExpectedState = Extract +export type TestingLibraryLocator = Locator & {within: () => LocatorQueries} + type ConvertQuery = 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, @@ -41,23 +50,31 @@ type ConvertQuery = Query extends ( id: Id, options?: Options, waitForOptions?: WaitForOptions & {state?: AsyncUtilExpectedState}, - ) => Promise + ) => LocatorPromise : never -type KebabCase = S extends `${infer C}${infer T}` - ? T extends Uncapitalize - ? `${Uncapitalize}${KebabCase}` - : `${Uncapitalize}-${KebabCase}` - : S - export type LocatorQueries = {[K in keyof Queries]: ConvertQuery} -export type WithinReturn = Root extends Page ? Screen : LocatorQueries +type ConvertQueryDeferred = Query extends ( + ...rest: infer Rest +) => any + ? (...args: Rest) => LocatorPromise + : never + +export type DeferredLocatorQueries = { + [K in keyof LocatorQueries]: ConvertQueryDeferred +} + +export type WithinReturn = Root extends Page ? Screen : QueriesReturn +export type QueriesReturn = Root extends LocatorPromise + ? DeferredLocatorQueries + : LocatorQueries + +export type QueryRoot = Page | Locator | LocatorPromise export type Screen = LocatorQueries & Page -export type Within = (locator: Root) => WithinReturn +export type Within = (locator: Root) => WithinReturn export type Query = keyof Queries - export type AllQuery = Extract export type FindQuery = Extract export type GetQuery = Extract diff --git a/playwright.config.ts b/playwright.config.ts index 9a4d6c3..3e80754 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -4,6 +4,7 @@ const config: PlaywrightTestConfig = { reporter: 'list', testDir: 'test/fixture', use: {actionTimeout: 3000}, + timeout: 5 * 1000, } export default config diff --git a/test/fixture/locators.test.ts b/test/fixture/locators.test.ts index 2ba602d..ef69838 100644 --- a/test/fixture/locators.test.ts +++ b/test/fixture/locators.test.ts @@ -410,4 +410,139 @@ 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 an asynchronous query', async ({screen}) => { + const locator = await screen + .findByRole('dialog') + .within() + .getByTestId('image-container') + .within() + .getByRole('img') + + expect(await locator.getAttribute('alt')).toEqual('Some modal image') + }) + + test('chaining an asynchronous query between synchronous queries', async ({screen}) => { + const locator = await screen + .getByTestId('modal-container') + .within() + .findByRole('dialog') + .within() + .getByRole('img') + + expect(await locator.getAttribute('alt')).toEqual('Some modal image') + }) + + test('chaining multiple asynchronous queries', async ({screen}) => { + const locator = await screen + .findByRole('dialog') + .within() + .findByRole('button', {name: 'Close'}) + + expect(await locator.textContent()).toEqual('Close') + }) + + 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') + }) + + test.describe('configuring chained queries', () => { + test.use({ + testIdAttribute: 'data-customid', + asyncUtilTimeout: 1000, + actionTimeout: 2000, + }) + + test('chained asynchronous queries inherit `asyncUtilTimeout`', async ({screen}) => { + screen.setDefaultTimeout(3000) + + const query = async () => screen.getByRole('figure').within().findByRole('img') + + await expect(query).rejects.toThrowError( + expect.objectContaining({ + message: expect.stringContaining('TestingLibraryElementError'), + }), + ) + }) + + test('chained asynchronous queries inherit `testIdAttribute`', async ({screen}) => { + const locator = await screen + .getByRole('figure') + .within() + .findByTestId('some-image', undefined, {timeout: 3000}) + + expect(await locator.getAttribute('alt')).toEqual('Some image') + }) + + test('subsequent chained asynchronous queries inherit `asyncUtilTimeout`', async ({ + screen, + }) => { + const query = async () => + screen + .findByRole('dialog', undefined, {timeout: 3000}) // We want this one to succeed + .within() + .findByRole('button', {name: 'Close'}) // This should inherit the `1000` timeout and fail + + await expect(query).rejects.toThrowError( + expect.objectContaining({ + message: expect.stringContaining('TestingLibraryElementError'), + }), + ) + }) + + test('synchronous query with multiple chained asynchronous queries inherit `asyncUtilTimeout`', async ({ + screen, + }) => { + const query = async () => + screen + .getByTestId('modal-container-custom-id') + .within() + .findByRole('dialog', undefined, {timeout: 3000}) // We want this one to succeed + .within() + .findByRole('button', {name: 'Close'}) // This should inherit the `1000` timeout and fail + + await expect(query).rejects.toThrowError( + expect.objectContaining({ + message: expect.stringContaining('TestingLibraryElementError'), + }), + ) + }) + }) + }) }) diff --git a/test/fixtures/chaining.html b/test/fixtures/chaining.html new file mode 100644 index 0000000..07a28e0 --- /dev/null +++ b/test/fixtures/chaining.html @@ -0,0 +1,69 @@ + + + +
+
Loading...
+
Some image
+
+