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... +