From 53fc2ce4b28e05a7e57fe56b2605bb1f66b9a56e Mon Sep 17 00:00:00 2001 From: Jamie Rolfs Date: Wed, 31 Aug 2022 21:27:13 -0700 Subject: [PATCH 1/4] feat(fixture): add support for `find*` queries in locator fixture --- lib/fixture/index.ts | 10 +-- lib/fixture/locator/fixtures.ts | 46 ++++++++++--- lib/fixture/locator/helpers.ts | 61 ++++++++++++++++-- lib/fixture/locator/index.ts | 2 +- lib/fixture/types.ts | 23 +++++-- test/fixture/locators.test.ts | 110 +++++++++++++++++++++++++++++++- test/fixtures/late-page.html | 9 +++ 7 files changed, 236 insertions(+), 25 deletions(-) diff --git a/lib/fixture/index.ts b/lib/fixture/index.ts index 21c8846..78d032e 100644 --- a/lib/fixture/index.ts +++ b/lib/fixture/index.ts @@ -1,7 +1,5 @@ import {Fixtures} from '@playwright/test' -import {Config} from '../common' - import type {Queries as ElementHandleQueries} from './element-handle' import {queriesFixture as elementHandleQueriesFixture} from './element-handle' import type {Queries as LocatorQueries} from './locator' @@ -10,12 +8,15 @@ import { queriesFixture as locatorQueriesFixture, options, registerSelectorsFixture, - within, + withinFixture, } from './locator' +import type {Config} from './types' +import {Within} from './types' const elementHandleFixtures: Fixtures = {queries: elementHandleQueriesFixture} const locatorFixtures: Fixtures = { queries: locatorQueriesFixture, + within: withinFixture, registerSelectors: registerSelectorsFixture, installTestingLibrary: installTestingLibraryFixture, ...options, @@ -27,6 +28,7 @@ interface ElementHandleFixtures { interface LocatorFixtures extends Partial { queries: LocatorQueries + within: Within registerSelectors: void installTestingLibrary: void } @@ -38,4 +40,4 @@ export {elementHandleQueriesFixture as fixture} export {elementHandleFixtures as fixtures} export type {LocatorFixtures} export {locatorQueriesFixture} -export {locatorFixtures, within} +export {locatorFixtures} diff --git a/lib/fixture/locator/fixtures.ts b/lib/fixture/locator/fixtures.ts index 1641256..09ff236 100644 --- a/lib/fixture/locator/fixtures.ts +++ b/lib/fixture/locator/fixtures.ts @@ -1,7 +1,13 @@ import type {Locator, PlaywrightTestArgs, TestFixture} from '@playwright/test' import {selectors} from '@playwright/test' -import type {Config, LocatorQueries as Queries, SelectorEngine, SynchronousQuery} from '../types' +import type { + Config, + LocatorQueries as Queries, + SelectorEngine, + SynchronousQuery, + Within, +} from '../types' import { buildTestingLibraryScript, @@ -11,16 +17,30 @@ import { synchronousQueryNames, } from './helpers' -const defaultConfig: Config = {testIdAttribute: 'data-testid', asyncUtilTimeout: 1000} +type TestArguments = PlaywrightTestArgs & Config + +const defaultConfig: Config = { + asyncUtilExpectedState: 'visible', + asyncUtilTimeout: 1000, + testIdAttribute: 'data-testid', +} const options = Object.fromEntries( Object.entries(defaultConfig).map(([key, value]) => [key, [value, {option: true}] as const]), ) -const queriesFixture: TestFixture = async ({page}, use) => - use(queriesFor(page)) +const queriesFixture: TestFixture = async ( + {page, asyncUtilExpectedState, asyncUtilTimeout}, + use, +) => use(queriesFor(page, {asyncUtilExpectedState, asyncUtilTimeout})) -const within = (locator: Locator): Queries => queriesFor(locator) +const withinFixture: TestFixture = async ( + {asyncUtilExpectedState, asyncUtilTimeout}, + use, +) => + use( + (locator: Locator): Queries => queriesFor(locator, {asyncUtilExpectedState, asyncUtilTimeout}), + ) declare const queryName: SynchronousQuery @@ -82,12 +102,14 @@ const registerSelectorsFixture: [ ] const installTestingLibraryFixture: [ - TestFixture, + TestFixture, {scope: 'test'; auto?: boolean}, ] = [ - async ({context, asyncUtilTimeout, testIdAttribute}, use) => { + async ({context, asyncUtilExpectedState, asyncUtilTimeout, testIdAttribute}, use) => { await context.addInitScript( - await buildTestingLibraryScript({config: {asyncUtilTimeout, testIdAttribute}}), + await buildTestingLibraryScript({ + config: {asyncUtilExpectedState, asyncUtilTimeout, testIdAttribute}, + }), ) await use() @@ -95,5 +117,11 @@ const installTestingLibraryFixture: [ {scope: 'test', auto: true}, ] -export {installTestingLibraryFixture, options, queriesFixture, registerSelectorsFixture, within} +export { + installTestingLibraryFixture, + options, + queriesFixture, + registerSelectorsFixture, + withinFixture, +} export type {Queries} diff --git a/lib/fixture/locator/helpers.ts b/lib/fixture/locator/helpers.ts index 622738c..fac1ddc 100644 --- a/lib/fixture/locator/helpers.ts +++ b/lib/fixture/locator/helpers.ts @@ -1,6 +1,7 @@ import {promises as fs} from 'fs' import type {Locator, Page} from '@playwright/test' +import {errors} from '@playwright/test' import {queries} from '@testing-library/dom' import {configureTestingLibraryScript} from '../../common' @@ -9,8 +10,10 @@ import type { AllQuery, Config, FindQuery, + GetQuery, LocatorQueries as Queries, Query, + QueryQuery, Selector, SynchronousQuery, } from '../types' @@ -18,9 +21,14 @@ import type { const allQueryNames = Object.keys(queries) as Query[] const isAllQuery = (query: Query): query is AllQuery => query.includes('All') + +const isFindQuery = (query: Query): query is FindQuery => query.startsWith('find') const isNotFindQuery = (query: Query): query is Exclude => !query.startsWith('find') +const findQueryToGetQuery = (query: FindQuery) => query.replace(/^find/, 'get') as GetQuery +const findQueryToQueryQuery = (query: FindQuery) => query.replace(/^find/, 'query') as QueryQuery + const queryToSelector = (query: SynchronousQuery) => query.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase() as Selector @@ -41,12 +49,57 @@ const buildTestingLibraryScript = async ({config}: {config: Config}) => { const synchronousQueryNames = allQueryNames.filter(isNotFindQuery) -const queriesFor = (pageOrLocator: Page | Locator) => - synchronousQueryNames.reduce( +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 = asyncUtilExpectedState, timeout = asyncUtilTimeout} = waitForElementOptions ?? {} + + try { + await locator.first().waitFor({state, 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) { + return pageOrLocator + .locator( + `${queryToSelector(findQueryToGetQuery(query))}=${JSON.stringify( + synchronousOptions, + replacer, + )}`, + ) + .first() + .waitFor({state, timeout: 100}) + } + + throw error + } + + return locator + } + +const queriesFor = (pageOrLocator: Page | Locator, config?: Partial) => + allQueryNames.reduce( (rest, query) => ({ ...rest, - [query]: (...args: Parameters) => - pageOrLocator.locator(`${queryToSelector(query)}=${JSON.stringify(args, replacer)}`), + [query]: isFindQuery(query) + ? createFindQuery(pageOrLocator, query, config) + : (...args: Parameters) => + pageOrLocator.locator(`${queryToSelector(query)}=${JSON.stringify(args, replacer)}`), }), {} as Queries, ) diff --git a/lib/fixture/locator/index.ts b/lib/fixture/locator/index.ts index f2ad787..5e3ef74 100644 --- a/lib/fixture/locator/index.ts +++ b/lib/fixture/locator/index.ts @@ -3,6 +3,6 @@ export { options, queriesFixture, registerSelectorsFixture, - within, + withinFixture, } from './fixtures' export type {Queries} from './fixtures' diff --git a/lib/fixture/types.ts b/lib/fixture/types.ts index 974de30..80b7225 100644 --- a/lib/fixture/types.ts +++ b/lib/fixture/types.ts @@ -2,7 +2,7 @@ import {Locator} from '@playwright/test' import type * as TestingLibraryDom from '@testing-library/dom' import {queries} from '@testing-library/dom' -import {Config} from '../common' +import type {Config as CommonConfig} from '../common' import {reviver} from './helpers' @@ -23,13 +23,25 @@ export type SelectorEngine = { } type Queries = typeof queries +type WaitForState = Exclude[0], undefined>['state'] +type AsyncUtilExpectedState = Extract -type StripNever = {[P in keyof T as T[P] extends never ? never : P]: T[P]} type ConvertQuery = Query extends ( el: HTMLElement, ...rest: infer Rest ) => HTMLElement | (HTMLElement[] | null) | (HTMLElement | null) ? (...args: Rest) => Locator + : Query extends ( + el: HTMLElement, + id: infer Id, + options: infer Options, + waitForOptions: infer WaitForOptions, + ) => Promise + ? ( + id: Id, + options?: Options, + waitForOptions?: WaitForOptions & {state?: AsyncUtilExpectedState}, + ) => Promise : never type KebabCase = S extends `${infer C}${infer T}` @@ -38,7 +50,7 @@ type KebabCase = S extends `${infer C}${infer T}` : `${Uncapitalize}-${KebabCase}` : S -export type LocatorQueries = StripNever<{[K in keyof Queries]: ConvertQuery}> +export type LocatorQueries = {[K in keyof Queries]: ConvertQuery} export type Within = (locator: Locator) => LocatorQueries export type Query = keyof Queries @@ -46,11 +58,14 @@ export type Query = keyof Queries export type AllQuery = Extract export type FindQuery = Extract export type GetQuery = Extract +export type QueryQuery = Extract export type SynchronousQuery = Exclude export type Selector = KebabCase -export type {Config} +export interface Config extends CommonConfig { + asyncUtilExpectedState: AsyncUtilExpectedState +} export interface ConfigFn { (existingConfig: Config): Partial } diff --git a/test/fixture/locators.test.ts b/test/fixture/locators.test.ts index 7dbee45..5b4cef9 100644 --- a/test/fixture/locators.test.ts +++ b/test/fixture/locators.test.ts @@ -5,7 +5,6 @@ import * as playwright from '@playwright/test' import { LocatorFixtures as TestingLibraryFixtures, locatorFixtures as fixtures, - within, } from '../../lib/fixture' const test = playwright.test.extend(fixtures) @@ -138,7 +137,7 @@ test.describe('lib/fixture.ts (locators)', () => { }) }) - test('scopes to container with `within`', async ({queries: {queryByRole}}) => { + test('scopes to container with `within`', async ({queries: {queryByRole}, within}) => { const form = queryByRole('form', {name: 'User'}) const {queryByLabelText} = within(form) @@ -173,5 +172,110 @@ test.describe('lib/fixture.ts (locators)', () => { }) }) - // TDOO: deferred page (do we need some alternative to `findBy*`?) + test.describe('deferred page', () => { + test.beforeEach(async ({page}) => { + await page.goto(`file://${path.join(__dirname, '../fixtures/late-page.html')}`) + }) + + test.afterEach(async ({page}) => page.close()) + + test('should handle the findBy* methods', async ({queries}) => { + const locator = await queries.findByText('Loaded!', undefined, {timeout: 7000}) + + expect(await locator.textContent()).toEqual('Loaded!') + }) + + test('should handle the findAllBy* methods', async ({queries}) => { + const locator = await queries.findAllByText(/Hello/, undefined, {timeout: 7000}) + + const text = await Promise.all([locator.nth(0).textContent(), locator.nth(1).textContent()]) + + expect(text).toEqual(['Hello h1', 'Hello h2']) + }) + + test('throws Testing Library error when locator times out', async ({queries}) => { + const query = async () => queries.findByText(/Loaded!/, undefined, {timeout: 1000}) + + await expect(query).rejects.toThrowError( + expect.objectContaining({ + message: expect.stringContaining('TestingLibraryElementError'), + }), + ) + }) + + test('throws Testing Library error when multi-element locator times out', async ({queries}) => { + const query = async () => queries.findAllByText(/Hello/, undefined, {timeout: 1000}) + + await expect(query).rejects.toThrowError( + expect.objectContaining({ + message: expect.stringContaining('TestingLibraryElementError'), + }), + ) + }) + + test.describe('configuring asynchronous queries via `use`', () => { + test.use({asyncUtilTimeout: 7000}) + + test('reads timeout configuration from `use` configuration', async ({queries, page}) => { + // Ensure this test fails if we don't set `timeout` correctly in the `waitFor` in our find query + page.setDefaultTimeout(4000) + + const locator = await queries.findByText('Loaded!') + + expect(await locator.textContent()).toEqual('Loaded!') + }) + }) + + test('waits for hidden element to be visible when `visible` is passed for state', async ({ + queries, + }) => { + await expect(queries.getByText('Hidden')).toBeHidden() + + const locator = await queries.findByText('Hidden', undefined, { + timeout: 7000, + state: 'visible', + }) + + expect(await locator.textContent()).toEqual('Hidden') + }) + + test.describe('configuring asynchronous queries with `visible` state', () => { + test.use({asyncUtilExpectedState: 'visible'}) + + test('waits for hidden element to be visible', async ({queries}) => { + await expect(queries.getByText('Hidden')).toBeHidden() + + const locator = await queries.findByText('Hidden', undefined, {timeout: 7000}) + + expect(await locator.textContent()).toEqual('Hidden') + }) + }) + + test('waits for hidden element to be attached when `attached` is passed for state', async ({ + queries, + }) => { + await expect(queries.queryByText('Attached')).toHaveCount(0) + + const locator = await queries.findByText('Attached', undefined, { + timeout: 7000, + state: 'attached', + }) + + expect(await locator.textContent()).toEqual('Attached') + await expect(locator).toBeHidden() + }) + + test.describe('configuring asynchronous queries with `attached` state', () => { + test.use({asyncUtilExpectedState: 'attached'}) + + test('waits for hidden element to be attached', async ({queries}) => { + await expect(queries.queryByText('Attached')).toHaveCount(0) + + const locator = await queries.findByText('Attached', undefined, {timeout: 7000}) + + expect(await locator.textContent()).toEqual('Attached') + await expect(locator).toBeHidden() + }) + }) + }) }) diff --git a/test/fixtures/late-page.html b/test/fixtures/late-page.html index 87f5474..8c4077e 100644 --- a/test/fixtures/late-page.html +++ b/test/fixtures/late-page.html @@ -2,6 +2,7 @@ Loading... + From 741de817c9ca947b35d5de04290c486ee98a84d4 Mon Sep 17 00:00:00 2001 From: Jamie Rolfs Date: Fri, 2 Sep 2022 18:09:40 -0700 Subject: [PATCH 2/4] docs(readme): include `find` queries in readme description --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 50d6adf..145d15b 100644 --- a/README.md +++ b/README.md @@ -137,7 +137,7 @@ Unique methods, not part of **@testing-library/dom** --- -The **[@testing-library/dom](https://github.com/testing-library/dom-testing-library#usage)** — All **`get*`** and **`query*`** methods are supported. +The **[@testing-library/dom](https://github.com/testing-library/dom-testing-library#usage)** — All **`get*`**, **`query*`**, and **`find*`** methods are supported. - `getQueriesForElement(handle: ElementHandle): ElementHandle & QueryUtils` - extend the input object with the query API and return it - `getNodeText(handle: ElementHandle): Promise` - get the text content of the element From 877b043d4e7a617b8f92be686cec2ebf9cad033d Mon Sep 17 00:00:00 2001 From: Jamie Rolfs Date: Fri, 2 Sep 2022 18:15:07 -0700 Subject: [PATCH 3/4] feat(fixture): expose unofficial `queriesFor` helper (until we have an official API for \`Locator\` queries that doesn't require a **@playwright/test** fixture) --- lib/fixture/index.ts | 15 +++++++++------ lib/fixture/locator/helpers.ts | 14 ++++++++++++++ lib/fixture/locator/index.ts | 1 + 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/lib/fixture/index.ts b/lib/fixture/index.ts index 78d032e..7eb3c42 100644 --- a/lib/fixture/index.ts +++ b/lib/fixture/index.ts @@ -7,6 +7,7 @@ import { installTestingLibraryFixture, queriesFixture as locatorQueriesFixture, options, + queriesFor, registerSelectorsFixture, withinFixture, } from './locator' @@ -35,9 +36,11 @@ interface LocatorFixtures extends Partial { export {configure} from '..' -export type {ElementHandleFixtures as TestingLibraryFixtures} -export {elementHandleQueriesFixture as fixture} -export {elementHandleFixtures as fixtures} -export type {LocatorFixtures} -export {locatorQueriesFixture} -export {locatorFixtures} +export type {ElementHandleFixtures as TestingLibraryFixtures, LocatorFixtures} +export { + locatorFixtures, + locatorQueriesFixture, + elementHandleQueriesFixture as fixture, + elementHandleFixtures as fixtures, + queriesFor, +} diff --git a/lib/fixture/locator/helpers.ts b/lib/fixture/locator/helpers.ts index fac1ddc..ff12d69 100644 --- a/lib/fixture/locator/helpers.ts +++ b/lib/fixture/locator/helpers.ts @@ -92,6 +92,20 @@ const createFindQuery = return locator } +/** + * Given a `Page` or `Locator` instance, return an object of Testing Library + * query methods that return a `Locator` instance for the queried element + * + * @internal this API is not currently intended for public usage and may be + * removed or changed outside of semantic release versioning. If possible, you + * should use the `locatorFixtures` with **@playwright/test** instead. + * @see {@link locatorFixtures} + * + * @param pageOrLocator `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) => allQueryNames.reduce( (rest, query) => ({ diff --git a/lib/fixture/locator/index.ts b/lib/fixture/locator/index.ts index 5e3ef74..f5b4dd3 100644 --- a/lib/fixture/locator/index.ts +++ b/lib/fixture/locator/index.ts @@ -6,3 +6,4 @@ export { withinFixture, } from './fixtures' export type {Queries} from './fixtures' +export {queriesFor} from './helpers' From a9cb165bfc43f6653833c6b7a758b4f46ed9e567 Mon Sep 17 00:00:00 2001 From: Jamie Rolfs Date: Fri, 2 Sep 2022 18:15:42 -0700 Subject: [PATCH 4/4] docs: add inline documentation for `configure` --- lib/index.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/lib/index.ts b/lib/index.ts index 7640d62..45f444c 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -176,6 +176,19 @@ export function wait( export const waitFor = wait +/** + * Configuration API for legacy queries that return `ElementHandle` instances. + * Only `testIdAttribute` and `asyncUtilTimeout` are currently supported. + + * @see {@link https://testing-library.com/docs/dom-testing-library/api-configuration} + * + * ⚠️ This API has no effect on the queries that return `Locator` instances. Use + * `test.use` instead to configure the `Locator` queries. + * + * @see {@link https://github.com/testing-library/playwright-testing-library/releases/tag/v4.4.0-beta.2} + * + * @param config + */ export function configure(config: Partial): void { if (!config) { return