From f159254889dd15b332a6dbae0bb0ab2bc19cb31e Mon Sep 17 00:00:00 2001 From: Jamie Rolfs Date: Wed, 13 Apr 2022 11:56:57 -0700 Subject: [PATCH 01/21] refactor(fixture): move `ElementHandle` based fixture into its own module --- lib/{fixture.ts => fixture/element-handle.ts} | 20 ++++++------------- lib/fixture/index.ts | 18 +++++++++++++++++ ...ixture.test.ts => element-handles.test.ts} | 0 3 files changed, 24 insertions(+), 14 deletions(-) rename lib/{fixture.ts => fixture/element-handle.ts} (50%) create mode 100644 lib/fixture/index.ts rename test/fixture/{fixture.test.ts => element-handles.test.ts} (100%) diff --git a/lib/fixture.ts b/lib/fixture/element-handle.ts similarity index 50% rename from lib/fixture.ts rename to lib/fixture/element-handle.ts index c3f6978..7bc12c1 100644 --- a/lib/fixture.ts +++ b/lib/fixture/element-handle.ts @@ -1,15 +1,10 @@ import type {PlaywrightTestArgs, TestFixture} from '@playwright/test' -import {queryNames} from './common' -import type {FixtureQueries as Queries} from './typedefs' +import {getDocument, queries as unscopedQueries} from '..' +import {queryNames} from '../common' +import type {FixtureQueries as Queries} from '../typedefs' -import {getDocument, queries as unscopedQueries} from '.' - -interface TestingLibraryFixtures { - queries: Queries -} - -const fixture: TestFixture = async ({page}, use) => { +const queriesFixture: TestFixture = async ({page}, use) => { const queries = {} as Queries queryNames.forEach(name => { @@ -27,8 +22,5 @@ const fixture: TestFixture = async ({page}, use) => await use(queries) } -const fixtures = {queries: fixture} - -export {configure} from '.' -export {fixture, fixtures} -export type {Queries, TestingLibraryFixtures} +export {queriesFixture} +export type {Queries} diff --git a/lib/fixture/index.ts b/lib/fixture/index.ts new file mode 100644 index 0000000..36da601 --- /dev/null +++ b/lib/fixture/index.ts @@ -0,0 +1,18 @@ +import {Fixtures} from '@playwright/test' + +import { + Queries as ElementHandleQueries, + queriesFixture as elementHandleQueriesFixture, +} from './element-handle' + +const elementHandleFixtures: Fixtures = {queries: elementHandleQueriesFixture} + +interface ElementHandleFixtures { + queries: ElementHandleQueries +} + +export type {ElementHandleFixtures as TestingLibraryFixtures} +export {elementHandleQueriesFixture as fixture} +export {elementHandleFixtures as fixtures} + +export {configure} from '..' diff --git a/test/fixture/fixture.test.ts b/test/fixture/element-handles.test.ts similarity index 100% rename from test/fixture/fixture.test.ts rename to test/fixture/element-handles.test.ts From b9036c3dfc14d41aca03042586fcfb54b3c87424 Mon Sep 17 00:00:00 2001 From: Jamie Rolfs Date: Wed, 27 Apr 2022 11:41:52 -0700 Subject: [PATCH 02/21] refactor(common): generate type for queries array from Testing Library --- lib/common.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/common.ts b/lib/common.ts index e515a5a..feec307 100644 --- a/lib/common.ts +++ b/lib/common.ts @@ -1,6 +1,6 @@ -import {Queries} from './typedefs' +import {queries} from '@testing-library/dom' -export const queryNames: Array = [ +export const queryNames: Array = [ 'queryByPlaceholderText', 'queryAllByPlaceholderText', 'getByPlaceholderText', From d8852c42ca686e3c80b198a7d0a31c381751325c Mon Sep 17 00:00:00 2001 From: Jamie Rolfs Date: Wed, 13 Apr 2022 11:58:28 -0700 Subject: [PATCH 03/21] feat(fixture): add `locatorFixtures` that provide `Locator`-based queries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This will likely replace the fixtures that provided `ElementHandle`-based queries in a future major release, but for now the `Locator` queries are exported as `locatorFixtures`: ```ts import { test as baseTest } from '@playwright/test' import { locatorFixtures as fixtures, LocatorFixtures as TestingLibraryFixtures, within } from '@playwright-testing-library/test/fixture'; const test = baseTest.extend(fixtures); const {expect} = test; test('my form', async ({queries: {getByTestId}}) => { // Queries now return `Locator` const formLocator = getByTestId('my-form'); // Locator-based `within` support const {getByLabelText} = within(formLocator); const emailInputLocator = getByLabelText('Email'); // Interact via `Locator` API 🥳 await emailInputLocator.fill('email@playwright.dev'); // Assert via `Locator` APIs 🎉 await expect(emailInputLocator).toHaveValue('email@playwright.dev'); }) ``` --- .eslintrc.js | 7 ++ .github/workflows/build.yml | 10 ++- lib/fixture/helpers.ts | 17 ++++ lib/fixture/index.ts | 26 +++++- lib/fixture/locator.ts | 132 +++++++++++++++++++++++++++++ lib/fixture/types.ts | 54 ++++++++++++ package.json | 5 +- test/fixture/locators.test.ts | 152 ++++++++++++++++++++++++++++++++++ test/fixtures/page.html | 12 +++ 9 files changed, 409 insertions(+), 6 deletions(-) create mode 100644 lib/fixture/helpers.ts create mode 100644 lib/fixture/locator.ts create mode 100644 lib/fixture/types.ts create mode 100644 test/fixture/locators.test.ts diff --git a/.eslintrc.js b/.eslintrc.js index ca50012..65e8203 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -28,5 +28,12 @@ module.exports = { 'jest/no-done-callback': 'off', }, }, + { + files: ['lib/fixture/**/*.+(js|ts)'], + rules: { + 'no-empty-pattern': 'off', + 'no-underscore-dangle': ['error', {allow: ['__testingLibraryReviver']}], + }, + }, ], } diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 839fac0..a9b240d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -48,10 +48,18 @@ jobs: npm install @playwright/test@${{ matrix.playwright }} - name: Check types, run lint + tests + if: ${{ matrix.playwright == 'latest' }} run: | npm why playwright npm why @playwright/test - npm run validate + npm run test + + - name: Check types, run lint + tests + if: ${{ matrix.playwright != 'latest' }} + run: | + npm why playwright + npm why @playwright/test + npm run test:legacy # Only release on Node 14 diff --git a/lib/fixture/helpers.ts b/lib/fixture/helpers.ts new file mode 100644 index 0000000..cd4fef4 --- /dev/null +++ b/lib/fixture/helpers.ts @@ -0,0 +1,17 @@ +const replacer = (_: string, value: unknown) => { + if (value instanceof RegExp) return `__REGEXP ${value.toString()}` + + return value +} + +const reviver = (_: string, value: string) => { + if (value.toString().includes('__REGEXP ')) { + const match = /\/(.*)\/(.*)?/.exec(value.split('__REGEXP ')[1]) + + return new RegExp(match![1], match![2] || '') + } + + return value +} + +export {replacer, reviver} diff --git a/lib/fixture/index.ts b/lib/fixture/index.ts index 36da601..6e2e9af 100644 --- a/lib/fixture/index.ts +++ b/lib/fixture/index.ts @@ -1,18 +1,38 @@ import {Fixtures} from '@playwright/test' +import type {Queries as ElementHandleQueries} from './element-handle' +import {queriesFixture as elementHandleQueriesFixture} from './element-handle' +import type {Queries as LocatorQueries} from './locator' import { - Queries as ElementHandleQueries, - queriesFixture as elementHandleQueriesFixture, -} from './element-handle' + installTestingLibraryFixture, + queriesFixture as locatorQueriesFixture, + registerSelectorsFixture, + within, +} from './locator' const elementHandleFixtures: Fixtures = {queries: elementHandleQueriesFixture} +const locatorFixtures: Fixtures = { + queries: locatorQueriesFixture, + registerSelectors: registerSelectorsFixture, + installTestingLibrary: installTestingLibraryFixture, +} interface ElementHandleFixtures { queries: ElementHandleQueries } +interface LocatorFixtures { + queries: LocatorQueries + registerSelectors: void + installTestingLibrary: void +} + export type {ElementHandleFixtures as TestingLibraryFixtures} export {elementHandleQueriesFixture as fixture} export {elementHandleFixtures as fixtures} +export type {LocatorFixtures} +export {locatorQueriesFixture} +export {locatorFixtures, within} + export {configure} from '..' diff --git a/lib/fixture/locator.ts b/lib/fixture/locator.ts new file mode 100644 index 0000000..7c6f596 --- /dev/null +++ b/lib/fixture/locator.ts @@ -0,0 +1,132 @@ +import {promises as fs} from 'fs' + +import type {Locator, PlaywrightTestArgs, TestFixture} from '@playwright/test' +import {selectors} from '@playwright/test' + +import {queryNames as allQueryNames} from '../common' + +import {replacer, reviver} from './helpers' +import type { + AllQuery, + FindQuery, + LocatorQueries as Queries, + Query, + Selector, + SelectorEngine, + SupportedQuery, +} from './types' + +const isAllQuery = (query: Query): query is AllQuery => query.includes('All') +const isNotFindQuery = (query: Query): query is Exclude => + !query.startsWith('find') + +const queryNames = allQueryNames.filter(isNotFindQuery) + +const queryToSelector = (query: SupportedQuery) => + query.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase() as Selector + +const queriesFixture: TestFixture = async ({page}, use) => { + const queries = queryNames.reduce( + (rest, query) => ({ + ...rest, + [query]: (...args: Parameters) => + page.locator(`${queryToSelector(query)}=${JSON.stringify(args, replacer)}`), + }), + {} as Queries, + ) + + await use(queries) +} + +const within = (locator: Locator): Queries => + queryNames.reduce( + (rest, query) => ({ + ...rest, + [query]: (...args: Parameters) => + locator.locator(`${queryToSelector(query)}=${JSON.stringify(args, replacer)}`), + }), + {} as Queries, + ) + +declare const queryName: SupportedQuery + +const engine: () => SelectorEngine = () => ({ + query(root, selector) { + const args = JSON.parse(selector, window.__testingLibraryReviver) as unknown as Parameters< + Queries[typeof queryName] + > + + if (isAllQuery(queryName)) + throw new Error( + `PlaywrightTestingLibrary: the plural '${queryName}' was used to create this Locator`, + ) + + // @ts-expect-error + const result = window.TestingLibraryDom[queryName](root, ...args) + + return result + }, + queryAll(root, selector) { + const testingLibrary = window.TestingLibraryDom + const args = JSON.parse(selector, window.__testingLibraryReviver) as unknown as Parameters< + Queries[typeof queryName] + > + + // @ts-expect-error + const result = testingLibrary[queryName](root, ...args) + + if (!result) return [] + + return Array.isArray(result) ? result : [result] + }, +}) + +const registerSelectorsFixture: [ + TestFixture, + {scope: 'worker'; auto?: boolean}, +] = [ + async ({}, use) => { + try { + await Promise.all( + queryNames.map(async name => + selectors.register( + queryToSelector(name), + `(${engine.toString().replace(/queryName/g, `"${name}"`)})()`, + ), + ), + ) + } catch (error) { + // eslint-disable-next-line no-console + console.error( + 'PlaywrightTestingLibrary: failed to register Testing Library functions\n', + error, + ) + } + await use() + }, + {scope: 'worker', auto: true}, +] + +const installTestingLibraryFixture: [ + TestFixture, + {scope: 'test'; auto?: boolean}, +] = [ + async ({context}, use) => { + const testingLibraryDomUmdScript = await fs.readFile( + require.resolve('@testing-library/dom/dist/@testing-library/dom.umd.js'), + 'utf8', + ) + + await context.addInitScript(` + ${testingLibraryDomUmdScript} + + window.__testingLibraryReviver = ${reviver.toString()}; + `) + + await use() + }, + {scope: 'test', auto: true}, +] + +export {queriesFixture, registerSelectorsFixture, installTestingLibraryFixture, within} +export type {Queries} diff --git a/lib/fixture/types.ts b/lib/fixture/types.ts new file mode 100644 index 0000000..29e90ed --- /dev/null +++ b/lib/fixture/types.ts @@ -0,0 +1,54 @@ +import {Locator} from '@playwright/test' +import type * as TestingLibraryDom from '@testing-library/dom' +import {queries} from '@testing-library/dom' + +import {reviver} from './helpers' + +/** + * This type was copied across from Playwright + * + * @see {@link https://github.com/microsoft/playwright/blob/82ff85b106e31ffd7b3702aef260c9c460cfb10c/packages/playwright-core/src/client/types.ts#L108-L117} + */ +export type SelectorEngine = { + /** + * Returns the first element matching given selector in the root's subtree. + */ + query(root: HTMLElement, selector: string): HTMLElement | null + /** + * Returns all elements matching given selector in the root's subtree. + */ + queryAll(root: HTMLElement, selector: string): HTMLElement[] +} + +type Queries = typeof queries + +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 + : never + +type KebabCase = S extends `${infer C}${infer T}` + ? T extends Uncapitalize + ? `${Uncapitalize}${KebabCase}` + : `${Uncapitalize}-${KebabCase}` + : S + +export type LocatorQueries = StripNever<{[K in keyof Queries]: ConvertQuery}> + +export type Query = keyof Queries + +export type AllQuery = Extract +export type FindQuery = Extract +export type SupportedQuery = Exclude + +export type Selector = KebabCase + +declare global { + interface Window { + TestingLibraryDom: typeof TestingLibraryDom + __testingLibraryReviver: typeof reviver + } +} diff --git a/package.json b/package.json index 4d75eda..ac92b92 100644 --- a/package.json +++ b/package.json @@ -20,10 +20,11 @@ "prepublishOnly": "npm run build", "start:standalone": "hover-scripts test", "test": "run-s build:testing-library test:*", + "test:legacy": "run-s build:testing-library test:standalone test:fixture:legacy", "test:fixture": "playwright test", + "test:fixture:legacy": "playwright test test/fixture/element-handles.test.ts", "test:standalone": "hover-scripts test --no-watch", - "test:types": "tsc --noEmit", - "validate": "run-s test" + "test:types": "tsc --noEmit" }, "repository": { "type": "git", diff --git a/test/fixture/locators.test.ts b/test/fixture/locators.test.ts new file mode 100644 index 0000000..8669e33 --- /dev/null +++ b/test/fixture/locators.test.ts @@ -0,0 +1,152 @@ +import * as path from 'path' + +import * as playwright from '@playwright/test' + +import { + LocatorFixtures as TestingLibraryFixtures, + locatorFixtures as fixtures, + within, +} from '../../lib/fixture' + +const test = playwright.test.extend(fixtures) + +const {expect} = test + +test.describe('lib/fixture.ts (locators)', () => { + test.beforeEach(async ({page}) => { + await page.goto(`file://${path.join(__dirname, '../fixtures/page.html')}`) + }) + + test('should handle the query* methods', async ({queries: {queryByText}}) => { + const locator = queryByText('Hello h1') + + expect(locator).toBeTruthy() + expect(await locator.textContent()).toEqual('Hello h1') + }) + + test('should use the new v3 methods', async ({queries: {queryByRole}}) => { + const locator = queryByRole('presentation') + + expect(locator).toBeTruthy() + expect(await locator.textContent()).toContain('Layout table') + }) + + test('should handle regex matching', async ({queries: {queryByText}}) => { + const locator = queryByText(/HeLlO h(1|7)/i) + + expect(locator).toBeTruthy() + expect(await locator.textContent()).toEqual('Hello h1') + }) + + test('should handle the get* methods', async ({queries: {getByTestId}}) => { + const locator = getByTestId('testid-text-input') + + expect(await locator.evaluate(el => el.outerHTML)).toMatch( + ``, + ) + }) + + test('handles page navigations', async ({queries: {getByText}, page}) => { + await page.goto(`file://${path.join(__dirname, '../fixtures/page.html')}`) + + const locator = getByText('Hello h1') + + expect(await locator.textContent()).toEqual('Hello h1') + }) + + test('should handle the get* method failures', async ({queries}) => { + const {getByTitle} = queries + // Use the scoped element so the pretty HTML snapshot is smaller + + await expect(async () => getByTitle('missing').textContent()).rejects.toThrow() + }) + + test('should handle the LabelText methods', async ({queries}) => { + const {getByLabelText} = queries + const locator = getByLabelText('Label A') + + /* istanbul ignore next */ + expect(await locator.evaluate(el => el.outerHTML)).toMatch( + ``, + ) + }) + + test('should handle the queryAll* methods', async ({queries}) => { + const {queryAllByText} = queries + const locator = queryAllByText(/Hello/) + + expect(await locator.count()).toEqual(3) + + const text = await Promise.all([ + locator.nth(0).textContent(), + locator.nth(1).textContent(), + locator.nth(2).textContent(), + ]) + + expect(text).toEqual(['Hello h1', 'Hello h2', 'Hello h3']) + }) + + test('should handle the queryAll* methods with a selector', async ({queries}) => { + const {queryAllByText} = queries + const locator = queryAllByText(/Hello/, {selector: 'h2'}) + + expect(await locator.count()).toEqual(1) + + expect(await locator.textContent()).toEqual('Hello h2') + }) + + test('should handle the getBy* methods with a selector', async ({queries}) => { + const {getByText} = queries + const locator = getByText(/Hello/, {selector: 'h2'}) + + expect(await locator.textContent()).toEqual('Hello h2') + }) + + test('should handle the getBy* methods with a regex name', async ({queries}) => { + const {getByRole} = queries + const element = getByRole('button', {name: /getBy.*Test/}) + + expect(await element.textContent()).toEqual('getByRole Test') + }) + + test('supports `hidden` option when querying by role', async ({queries: {queryAllByRole}}) => { + const elements = queryAllByRole('img') + const hiddenElements = queryAllByRole('img', {hidden: true}) + + expect(await elements.count()).toEqual(1) + expect(await hiddenElements.count()).toEqual(2) + }) + + test.describe('querying by role with `level` option', () => { + test('retrieves the correct elements when querying all by role', async ({ + queries: {queryAllByRole}, + }) => { + const locator = queryAllByRole('heading') + const levelOneLocator = queryAllByRole('heading', {level: 3}) + + expect(await locator.count()).toEqual(3) + expect(await levelOneLocator.count()).toEqual(1) + }) + + test('does not throw when querying for a specific element', async ({queries: {getByRole}}) => { + expect.assertions(1) + + await expect(getByRole('heading', {level: 3}).textContent()).resolves.not.toThrow() + }) + }) + + test('scopes to container with `within`', async ({queries: {queryByRole}}) => { + const form = queryByRole('form', {name: 'User'}) + + const {queryByLabelText} = within(form) + + const outerLocator = queryByLabelText('Name') + const innerLocator = queryByLabelText('Username') + + expect(await outerLocator.count()).toBe(0) + expect(await innerLocator.count()).toBe(1) + }) + + // TODO: configuration + // TDOO: deferred page (do we need some alternative to `findBy*`?) +}) diff --git a/test/fixtures/page.html b/test/fixtures/page.html index a014c3a..c95039f 100644 --- a/test/fixtures/page.html +++ b/test/fixtures/page.html @@ -21,10 +21,22 @@

Hello h3

aria-hidden="true" role="img" xmlns="http://www.w3.org/2000/svg" + width="128px" viewBox="0 0 512 512" > + +
+ + +
+ + +
+ + +
From a8908504d5f4a0b4cfa6a6ef2e8c9738f715a7cc Mon Sep 17 00:00:00 2001 From: Jamie Rolfs Date: Mon, 2 May 2022 12:09:59 -0700 Subject: [PATCH 04/21] refactor: derive configuration options type for Testing Library types --- lib/typedefs.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/typedefs.ts b/lib/typedefs.ts index 9fcc5c7..1a9853c 100644 --- a/lib/typedefs.ts +++ b/lib/typedefs.ts @@ -1,6 +1,7 @@ import { Matcher, ByRoleOptions as TestingLibraryByRoleOptions, + Config as TestingLibraryConfig, MatcherOptions as TestingLibraryMatcherOptions, SelectorMatcherOptions as TestingLibrarySelectorMatcherOptions, waitForOptions, @@ -189,7 +190,7 @@ export interface Queries extends QueryMethods { getNodeText(el: Element): Promise } -export interface ConfigurationOptions { - testIdAttribute: string - asyncUtilTimeout: number -} +export type ConfigurationOptions = Pick< + TestingLibraryConfig, + 'testIdAttribute' | 'asyncUtilTimeout' +> From 6d47123431b185f067a98e10bc4922023ecc5f7f Mon Sep 17 00:00:00 2001 From: Jamie Rolfs Date: Fri, 26 Aug 2022 00:28:06 -0700 Subject: [PATCH 05/21] feat: support configure API with locator fixture via `test.use` ### Global ```ts // playwright.config.ts import type { PlaywrightTestConfig } from '@playwright/test'; const config: PlaywrightTestConfig = { use: { testIdAttribute: 'data-custom-test-id', asyncUtilsTimeout: 5000, }, }; export default config; ``` ### Local ```ts import { test as baseTest } from '@playwright/test' import { locatorFixtures as fixtures, LocatorFixtures as TestingLibraryFixtures, within } from '@playwright-testing-library/test/fixture'; const test = baseTest.extend(fixtures); const {expect} = test; // Entire test suite test.use({ testIdAttribute: 'data-custom-test-id' }); test.describe(() => { // Specific block test.use({ testIdAttribute: 'some-other-test-id', asyncUtilsTimeout: 5000, }); test('my form', async ({queries: {getByTestId}}) => { // ... }); }); ``` --- lib/common.ts | 20 +++++- lib/fixture/index.ts | 11 ++-- .../{locator.ts => locator/fixtures.ts} | 66 ++++++------------- lib/fixture/locator/helpers.ts | 29 ++++++++ lib/fixture/locator/index.ts | 8 +++ lib/fixture/types.ts | 12 ++++ lib/index.ts | 27 +++----- lib/typedefs.ts | 6 -- test/fixture/configure.test.ts | 48 ++++++++++++++ test/fixture/element-handles.test.ts | 2 - test/fixture/locators.test.ts | 25 ++++++- 11 files changed, 173 insertions(+), 81 deletions(-) rename lib/fixture/{locator.ts => locator/fixtures.ts} (56%) create mode 100644 lib/fixture/locator/helpers.ts create mode 100644 lib/fixture/locator/index.ts create mode 100644 test/fixture/configure.test.ts diff --git a/lib/common.ts b/lib/common.ts index feec307..09bcf7c 100644 --- a/lib/common.ts +++ b/lib/common.ts @@ -1,4 +1,22 @@ -import {queries} from '@testing-library/dom' +import {Config as TestingLibraryConfig, queries} from '@testing-library/dom' + +export type Config = Pick + +export const configureTestingLibraryScript = ( + script: string, + {testIdAttribute, asyncUtilTimeout}: Partial, +) => { + const withTestId = testIdAttribute + ? script.replace( + /testIdAttribute: (['|"])data-testid(['|"])/g, + `testIdAttribute: $1${testIdAttribute}$2`, + ) + : script + + return asyncUtilTimeout + ? withTestId.replace(/asyncUtilTimeout: \d+/g, `asyncUtilTimeout: ${asyncUtilTimeout}`) + : withTestId +} export const queryNames: Array = [ 'queryByPlaceholderText', diff --git a/lib/fixture/index.ts b/lib/fixture/index.ts index 6e2e9af..21c8846 100644 --- a/lib/fixture/index.ts +++ b/lib/fixture/index.ts @@ -1,11 +1,14 @@ 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' import { installTestingLibraryFixture, queriesFixture as locatorQueriesFixture, + options, registerSelectorsFixture, within, } from './locator' @@ -15,24 +18,24 @@ const locatorFixtures: Fixtures = { queries: locatorQueriesFixture, registerSelectors: registerSelectorsFixture, installTestingLibrary: installTestingLibraryFixture, + ...options, } interface ElementHandleFixtures { queries: ElementHandleQueries } -interface LocatorFixtures { +interface LocatorFixtures extends Partial { queries: LocatorQueries registerSelectors: void installTestingLibrary: void } +export {configure} from '..' + export type {ElementHandleFixtures as TestingLibraryFixtures} export {elementHandleQueriesFixture as fixture} export {elementHandleFixtures as fixtures} - export type {LocatorFixtures} export {locatorQueriesFixture} export {locatorFixtures, within} - -export {configure} from '..' diff --git a/lib/fixture/locator.ts b/lib/fixture/locator/fixtures.ts similarity index 56% rename from lib/fixture/locator.ts rename to lib/fixture/locator/fixtures.ts index 7c6f596..10095f6 100644 --- a/lib/fixture/locator.ts +++ b/lib/fixture/locator/fixtures.ts @@ -1,52 +1,33 @@ -import {promises as fs} from 'fs' - -import type {Locator, PlaywrightTestArgs, TestFixture} from '@playwright/test' +import type {Locator, Page, PlaywrightTestArgs, TestFixture} from '@playwright/test' import {selectors} from '@playwright/test' -import {queryNames as allQueryNames} from '../common' - -import {replacer, reviver} from './helpers' -import type { - AllQuery, - FindQuery, - LocatorQueries as Queries, - Query, - Selector, - SelectorEngine, - SupportedQuery, -} from './types' +import {queryNames as allQueryNames} from '../../common' +import {replacer} from '../helpers' +import type {Config, LocatorQueries as Queries, SelectorEngine, SupportedQuery} from '../types' -const isAllQuery = (query: Query): query is AllQuery => query.includes('All') -const isNotFindQuery = (query: Query): query is Exclude => - !query.startsWith('find') +import {buildTestingLibraryScript, isAllQuery, isNotFindQuery, queryToSelector} from './helpers' const queryNames = allQueryNames.filter(isNotFindQuery) +const defaultConfig: Config = {testIdAttribute: 'data-testid', asyncUtilTimeout: 1000} -const queryToSelector = (query: SupportedQuery) => - query.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase() as Selector +const options = Object.fromEntries( + Object.entries(defaultConfig).map(([key, value]) => [key, [value, {option: true}] as const]), +) -const queriesFixture: TestFixture = async ({page}, use) => { - const queries = queryNames.reduce( +const queriesFor = (pageOrLocator: Page | Locator) => + queryNames.reduce( (rest, query) => ({ ...rest, [query]: (...args: Parameters) => - page.locator(`${queryToSelector(query)}=${JSON.stringify(args, replacer)}`), + pageOrLocator.locator(`${queryToSelector(query)}=${JSON.stringify(args, replacer)}`), }), {} as Queries, ) - await use(queries) -} +const queriesFixture: TestFixture = async ({page}, use) => + use(queriesFor(page)) -const within = (locator: Locator): Queries => - queryNames.reduce( - (rest, query) => ({ - ...rest, - [query]: (...args: Parameters) => - locator.locator(`${queryToSelector(query)}=${JSON.stringify(args, replacer)}`), - }), - {} as Queries, - ) +const within = (locator: Locator): Queries => queriesFor(locator) declare const queryName: SupportedQuery @@ -108,25 +89,18 @@ const registerSelectorsFixture: [ ] const installTestingLibraryFixture: [ - TestFixture, + TestFixture, {scope: 'test'; auto?: boolean}, ] = [ - async ({context}, use) => { - const testingLibraryDomUmdScript = await fs.readFile( - require.resolve('@testing-library/dom/dist/@testing-library/dom.umd.js'), - 'utf8', + async ({context, asyncUtilTimeout, testIdAttribute}, use) => { + await context.addInitScript( + await buildTestingLibraryScript({config: {asyncUtilTimeout, testIdAttribute}}), ) - await context.addInitScript(` - ${testingLibraryDomUmdScript} - - window.__testingLibraryReviver = ${reviver.toString()}; - `) - await use() }, {scope: 'test', auto: true}, ] -export {queriesFixture, registerSelectorsFixture, installTestingLibraryFixture, within} +export {installTestingLibraryFixture, options, queriesFixture, registerSelectorsFixture, within} export type {Queries} diff --git a/lib/fixture/locator/helpers.ts b/lib/fixture/locator/helpers.ts new file mode 100644 index 0000000..a4829c9 --- /dev/null +++ b/lib/fixture/locator/helpers.ts @@ -0,0 +1,29 @@ +import {promises as fs} from 'fs' + +import {configureTestingLibraryScript} from '../../common' +import {reviver} from '../helpers' +import type {AllQuery, Config, FindQuery, Query, Selector, SupportedQuery} from '../types' + +const isAllQuery = (query: Query): query is AllQuery => query.includes('All') +const isNotFindQuery = (query: Query): query is Exclude => + !query.startsWith('find') + +const queryToSelector = (query: SupportedQuery) => + query.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase() as Selector + +const buildTestingLibraryScript = async ({config}: {config: Config}) => { + const testingLibraryDom = await fs.readFile( + require.resolve('@testing-library/dom/dist/@testing-library/dom.umd.js'), + 'utf8', + ) + + const configuredTestingLibraryDom = configureTestingLibraryScript(testingLibraryDom, config) + + return ` + ${configuredTestingLibraryDom} + + window.__testingLibraryReviver = ${reviver.toString()}; + ` +} + +export {isAllQuery, isNotFindQuery, queryToSelector, buildTestingLibraryScript} diff --git a/lib/fixture/locator/index.ts b/lib/fixture/locator/index.ts new file mode 100644 index 0000000..f2ad787 --- /dev/null +++ b/lib/fixture/locator/index.ts @@ -0,0 +1,8 @@ +export { + installTestingLibraryFixture, + options, + queriesFixture, + registerSelectorsFixture, + within, +} from './fixtures' +export type {Queries} from './fixtures' diff --git a/lib/fixture/types.ts b/lib/fixture/types.ts index 29e90ed..d8f9ef5 100644 --- a/lib/fixture/types.ts +++ b/lib/fixture/types.ts @@ -2,6 +2,8 @@ import {Locator} from '@playwright/test' import type * as TestingLibraryDom from '@testing-library/dom' import {queries} from '@testing-library/dom' +import {Config} from '../common' + import {reviver} from './helpers' /** @@ -37,6 +39,7 @@ type KebabCase = S extends `${infer C}${infer T}` : S export type LocatorQueries = StripNever<{[K in keyof Queries]: ConvertQuery}> +export type Within = (locator: Locator) => LocatorQueries export type Query = keyof Queries @@ -46,6 +49,15 @@ export type SupportedQuery = Exclude export type Selector = KebabCase +export type {Config} +export interface ConfigFn { + (existingConfig: Config): Partial +} + +export type ConfigDelta = ConfigFn | Partial +export type Configure = (configDelta: ConfigDelta) => void +export type ConfigureLocator = (configDelta: ConfigDelta) => Config + declare global { interface Window { TestingLibraryDom: typeof TestingLibraryDom diff --git a/lib/index.ts b/lib/index.ts index bbd2725..7640d62 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -6,8 +6,8 @@ import * as path from 'path' import {JSHandle, Page} from 'playwright' import waitForExpect from 'wait-for-expect' -import {queryNames} from './common' -import {ConfigurationOptions, ElementHandle, Queries, ScopedQueries} from './typedefs' +import {Config, configureTestingLibraryScript, queryNames} from './common' +import {ElementHandle, Queries, ScopedQueries} from './typedefs' const domLibraryAsString = readFileSync( path.join(__dirname, '../dom-testing-library.js'), @@ -176,26 +176,15 @@ export function wait( export const waitFor = wait -export function configure(options: Partial): void { - if (!options) { +export function configure(config: Partial): void { + if (!config) { return } - const {testIdAttribute, asyncUtilTimeout} = options - - if (testIdAttribute) { - delegateFnBodyToExecuteInPage = delegateFnBodyToExecuteInPageInitial.replace( - /testIdAttribute: (['|"])data-testid(['|"])/g, - `testIdAttribute: $1${testIdAttribute}$2`, - ) - } - - if (asyncUtilTimeout) { - delegateFnBodyToExecuteInPage = delegateFnBodyToExecuteInPageInitial.replace( - /asyncUtilTimeout: \d+/g, - `asyncUtilTimeout: ${asyncUtilTimeout}`, - ) - } + delegateFnBodyToExecuteInPage = configureTestingLibraryScript( + delegateFnBodyToExecuteInPageInitial, + config, + ) } export function getQueriesForElement( diff --git a/lib/typedefs.ts b/lib/typedefs.ts index 1a9853c..89a29cd 100644 --- a/lib/typedefs.ts +++ b/lib/typedefs.ts @@ -1,7 +1,6 @@ import { Matcher, ByRoleOptions as TestingLibraryByRoleOptions, - Config as TestingLibraryConfig, MatcherOptions as TestingLibraryMatcherOptions, SelectorMatcherOptions as TestingLibrarySelectorMatcherOptions, waitForOptions, @@ -189,8 +188,3 @@ export interface Queries extends QueryMethods { getQueriesForElement(): ScopedQueries getNodeText(el: Element): Promise } - -export type ConfigurationOptions = Pick< - TestingLibraryConfig, - 'testIdAttribute' | 'asyncUtilTimeout' -> diff --git a/test/fixture/configure.test.ts b/test/fixture/configure.test.ts new file mode 100644 index 0000000..dba69eb --- /dev/null +++ b/test/fixture/configure.test.ts @@ -0,0 +1,48 @@ +import * as path from 'path' + +import * as playwright from '@playwright/test' + +import { + LocatorFixtures as TestingLibraryFixtures, + locatorFixtures as fixtures, +} from '../../lib/fixture' + +const test = playwright.test.extend(fixtures) + +const {expect} = test + +test.use({testIdAttribute: 'data-new-id'}) + +test.describe('global configuration', () => { + test.beforeEach(async ({page}) => { + await page.goto(`file://${path.join(__dirname, '../fixtures/page.html')}`) + }) + + test('queries with test ID configured in module scope', async ({queries}) => { + const defaultTestIdLocator = queries.queryByTestId('testid-text-input') + const customTestIdLocator = queries.queryByTestId('first-level-header') + + await expect(defaultTestIdLocator).not.toBeVisible() + await expect(customTestIdLocator).toBeVisible() + }) + + test.describe('overridding global configuration', () => { + test.use({testIdAttribute: 'data-id'}) + + test('overrides test ID configured in module scope', async ({queries}) => { + const globalTestIdLocator = queries.queryByTestId('first-level-header') + const overriddenTestIdLocator = queries.queryByTestId('second-level-header') + + await expect(globalTestIdLocator).not.toBeVisible() + await expect(overriddenTestIdLocator).toBeVisible() + }) + }) + + test("page override doesn't modify global configuration", async ({queries}) => { + const defaultTestIdLocator = queries.queryByTestId('testid-text-input') + const customTestIdLocator = queries.queryByTestId('first-level-header') + + await expect(defaultTestIdLocator).not.toBeVisible() + await expect(customTestIdLocator).toBeVisible() + }) +}) diff --git a/test/fixture/element-handles.test.ts b/test/fixture/element-handles.test.ts index 0cee28f..f97261a 100644 --- a/test/fixture/element-handles.test.ts +++ b/test/fixture/element-handles.test.ts @@ -135,8 +135,6 @@ test.describe('lib/fixture.ts', () => { }) test('does not throw when querying for a specific element', async ({queries: {getByRole}}) => { - expect.assertions(1) - await expect(getByRole('heading', {level: 3})).resolves.not.toThrow() }) }) diff --git a/test/fixture/locators.test.ts b/test/fixture/locators.test.ts index 8669e33..7dc56ea 100644 --- a/test/fixture/locators.test.ts +++ b/test/fixture/locators.test.ts @@ -129,8 +129,6 @@ test.describe('lib/fixture.ts (locators)', () => { }) test('does not throw when querying for a specific element', async ({queries: {getByRole}}) => { - expect.assertions(1) - await expect(getByRole('heading', {level: 3}).textContent()).resolves.not.toThrow() }) }) @@ -147,6 +145,27 @@ test.describe('lib/fixture.ts (locators)', () => { expect(await innerLocator.count()).toBe(1) }) - // TODO: configuration + test.describe('configuration', () => { + test.describe('custom data-testeid', () => { + test.use({testIdAttribute: 'data-id'}) + + test('supports custom data-testid attribute name', async ({queries}) => { + const locator = queries.getByTestId('second-level-header') + + expect(await locator.textContent()).toEqual('Hello h2') + }) + }) + + test.describe('nested configuration', () => { + test.use({testIdAttribute: 'data-new-id'}) + + test('supports nested data-testid attribute names', async ({queries}) => { + const locator = queries.getByTestId('first-level-header') + + expect(await locator.textContent()).toEqual('Hello h1') + }) + }) + }) + // TDOO: deferred page (do we need some alternative to `findBy*`?) }) From b9c7bb425cdc76850239589e163167e18fa61162 Mon Sep 17 00:00:00 2001 From: Jamie Rolfs Date: Fri, 26 Aug 2022 12:41:21 -0700 Subject: [PATCH 06/21] test: oops, don't run duplicate test suites --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ac92b92..643dc4a 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "prepare:playwright-test:package": "jscodeshift -t ./playwright-test/rename-imports.ts --extensions=ts --parser=ts ./lib", "prepublishOnly": "npm run build", "start:standalone": "hover-scripts test", - "test": "run-s build:testing-library test:*", + "test": "run-s build:testing-library test:standalone test:fixture", "test:legacy": "run-s build:testing-library test:standalone test:fixture:legacy", "test:fixture": "playwright test", "test:fixture:legacy": "playwright test test/fixture/element-handles.test.ts", From e19abf830006c002dec30441770078191170c9be Mon Sep 17 00:00:00 2001 From: Jamie Rolfs Date: Wed, 31 Aug 2022 21:21:06 -0700 Subject: [PATCH 07/21] refactor: use `queries` from Testing Library to enumerate queries --- lib/fixture/locator/fixtures.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/lib/fixture/locator/fixtures.ts b/lib/fixture/locator/fixtures.ts index 10095f6..f137549 100644 --- a/lib/fixture/locator/fixtures.ts +++ b/lib/fixture/locator/fixtures.ts @@ -1,12 +1,20 @@ import type {Locator, Page, PlaywrightTestArgs, TestFixture} from '@playwright/test' import {selectors} from '@playwright/test' +import {queries} from '@testing-library/dom' -import {queryNames as allQueryNames} from '../../common' import {replacer} from '../helpers' -import type {Config, LocatorQueries as Queries, SelectorEngine, SupportedQuery} from '../types' +import type { + Config, + LocatorQueries as Queries, + Query, + SelectorEngine, + SupportedQuery, +} from '../types' import {buildTestingLibraryScript, isAllQuery, isNotFindQuery, queryToSelector} from './helpers' +const allQueryNames = Object.keys(queries) as Query[] + const queryNames = allQueryNames.filter(isNotFindQuery) const defaultConfig: Config = {testIdAttribute: 'data-testid', asyncUtilTimeout: 1000} From dea6e7a4079e5858ef1d8d5b36b0cfe5d411a48e Mon Sep 17 00:00:00 2001 From: Jamie Rolfs Date: Wed, 31 Aug 2022 21:23:51 -0700 Subject: [PATCH 08/21] =?UTF-8?q?refactor:=20rename=20'supported'=20?= =?UTF-8?q?=E2=86=92=20'synchronous'=20query,=20move=20helper=20stuff?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/fixture/locator/fixtures.ts | 37 ++++++++++----------------------- lib/fixture/locator/helpers.ts | 33 +++++++++++++++++++++++++---- lib/fixture/types.ts | 5 +++-- 3 files changed, 43 insertions(+), 32 deletions(-) diff --git a/lib/fixture/locator/fixtures.ts b/lib/fixture/locator/fixtures.ts index f137549..1641256 100644 --- a/lib/fixture/locator/fixtures.ts +++ b/lib/fixture/locator/fixtures.ts @@ -1,43 +1,28 @@ -import type {Locator, Page, PlaywrightTestArgs, TestFixture} from '@playwright/test' +import type {Locator, PlaywrightTestArgs, TestFixture} from '@playwright/test' import {selectors} from '@playwright/test' -import {queries} from '@testing-library/dom' -import {replacer} from '../helpers' -import type { - Config, - LocatorQueries as Queries, - Query, - SelectorEngine, - SupportedQuery, -} from '../types' +import type {Config, LocatorQueries as Queries, SelectorEngine, SynchronousQuery} from '../types' -import {buildTestingLibraryScript, isAllQuery, isNotFindQuery, queryToSelector} from './helpers' +import { + buildTestingLibraryScript, + isAllQuery, + queriesFor, + queryToSelector, + synchronousQueryNames, +} from './helpers' -const allQueryNames = Object.keys(queries) as Query[] - -const queryNames = allQueryNames.filter(isNotFindQuery) const defaultConfig: Config = {testIdAttribute: 'data-testid', asyncUtilTimeout: 1000} const options = Object.fromEntries( Object.entries(defaultConfig).map(([key, value]) => [key, [value, {option: true}] as const]), ) -const queriesFor = (pageOrLocator: Page | Locator) => - queryNames.reduce( - (rest, query) => ({ - ...rest, - [query]: (...args: Parameters) => - pageOrLocator.locator(`${queryToSelector(query)}=${JSON.stringify(args, replacer)}`), - }), - {} as Queries, - ) - const queriesFixture: TestFixture = async ({page}, use) => use(queriesFor(page)) const within = (locator: Locator): Queries => queriesFor(locator) -declare const queryName: SupportedQuery +declare const queryName: SynchronousQuery const engine: () => SelectorEngine = () => ({ query(root, selector) { @@ -77,7 +62,7 @@ const registerSelectorsFixture: [ async ({}, use) => { try { await Promise.all( - queryNames.map(async name => + synchronousQueryNames.map(async name => selectors.register( queryToSelector(name), `(${engine.toString().replace(/queryName/g, `"${name}"`)})()`, diff --git a/lib/fixture/locator/helpers.ts b/lib/fixture/locator/helpers.ts index a4829c9..622738c 100644 --- a/lib/fixture/locator/helpers.ts +++ b/lib/fixture/locator/helpers.ts @@ -1,14 +1,27 @@ import {promises as fs} from 'fs' +import type {Locator, Page} from '@playwright/test' +import {queries} from '@testing-library/dom' + import {configureTestingLibraryScript} from '../../common' -import {reviver} from '../helpers' -import type {AllQuery, Config, FindQuery, Query, Selector, SupportedQuery} from '../types' +import {replacer, reviver} from '../helpers' +import type { + AllQuery, + Config, + FindQuery, + LocatorQueries as Queries, + Query, + Selector, + SynchronousQuery, +} from '../types' + +const allQueryNames = Object.keys(queries) as Query[] const isAllQuery = (query: Query): query is AllQuery => query.includes('All') const isNotFindQuery = (query: Query): query is Exclude => !query.startsWith('find') -const queryToSelector = (query: SupportedQuery) => +const queryToSelector = (query: SynchronousQuery) => query.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase() as Selector const buildTestingLibraryScript = async ({config}: {config: Config}) => { @@ -26,4 +39,16 @@ const buildTestingLibraryScript = async ({config}: {config: Config}) => { ` } -export {isAllQuery, isNotFindQuery, queryToSelector, buildTestingLibraryScript} +const synchronousQueryNames = allQueryNames.filter(isNotFindQuery) + +const queriesFor = (pageOrLocator: Page | Locator) => + synchronousQueryNames.reduce( + (rest, query) => ({ + ...rest, + [query]: (...args: Parameters) => + pageOrLocator.locator(`${queryToSelector(query)}=${JSON.stringify(args, replacer)}`), + }), + {} as Queries, + ) + +export {buildTestingLibraryScript, isAllQuery, queriesFor, queryToSelector, synchronousQueryNames} diff --git a/lib/fixture/types.ts b/lib/fixture/types.ts index d8f9ef5..974de30 100644 --- a/lib/fixture/types.ts +++ b/lib/fixture/types.ts @@ -45,9 +45,10 @@ export type Query = keyof Queries export type AllQuery = Extract export type FindQuery = Extract -export type SupportedQuery = Exclude +export type GetQuery = Extract +export type SynchronousQuery = Exclude -export type Selector = KebabCase +export type Selector = KebabCase export type {Config} export interface ConfigFn { From bd7fb5cb82ef00654862272c5aeff732821a3be4 Mon Sep 17 00:00:00 2001 From: Jamie Rolfs Date: Wed, 31 Aug 2022 21:25:58 -0700 Subject: [PATCH 09/21] test: isolate 'standard' page (no timeout) in `describe` blocks --- test/fixture/element-handles.test.ts | 263 ++++++++++++++------------- test/fixture/locators.test.ts | 220 +++++++++++----------- 2 files changed, 249 insertions(+), 234 deletions(-) diff --git a/test/fixture/element-handles.test.ts b/test/fixture/element-handles.test.ts index f97261a..bd74084 100644 --- a/test/fixture/element-handles.test.ts +++ b/test/fixture/element-handles.test.ts @@ -10,187 +10,196 @@ const test = playwright.test.extend(fixtures) const {expect} = test test.describe('lib/fixture.ts', () => { - test.beforeEach(async ({page}) => { - await page.goto(`file://${path.join(__dirname, '../fixtures/page.html')}`) - }) - - test('should handle the query* methods', async ({queries: {queryByText}}) => { - const element = await queryByText('Hello h1') + test.describe('standard page', () => { + test.beforeEach(async ({page}) => { + await page.goto(`file://${path.join(__dirname, '../fixtures/page.html')}`) + }) - expect(element).toBeTruthy() - expect(await element.textContent()).toEqual('Hello h1') - }) + test.afterEach(async ({page}) => page.close()) - test('should use the new v3 methods', async ({queries: {queryByRole}}) => { - const element = await queryByRole('presentation') + test('should handle the query* methods', async ({queries: {queryByText}}) => { + const element = await queryByText('Hello h1') - expect(element).toBeTruthy() - expect(await element.textContent()).toContain('Layout table') - }) + expect(element).toBeTruthy() + expect(await element.textContent()).toEqual('Hello h1') + }) - test('should handle regex matching', async ({queries: {queryByText}}) => { - const element = await queryByText(/HeLlO h(1|7)/i) + test('should use the new v3 methods', async ({queries: {queryByRole}}) => { + const element = await queryByRole('presentation') - expect(element).toBeTruthy() - expect(await element.textContent()).toEqual('Hello h1') - }) + expect(element).toBeTruthy() + expect(await element.textContent()).toContain('Layout table') + }) - test('should handle the get* methods', async ({queries: {getByTestId}, page}) => { - const element = await getByTestId('testid-text-input') + test('should handle regex matching', async ({queries: {queryByText}}) => { + const element = await queryByText(/HeLlO h(1|7)/i) - expect(await page.evaluate(el => el.outerHTML, element)).toMatch( - ``, - ) - }) + expect(element).toBeTruthy() + expect(await element.textContent()).toEqual('Hello h1') + }) - test('attaches `getNodeText`', async ({queries}) => { - const element = await queries.getByText('Hello h1') + test('should handle the get* methods', async ({queries: {getByTestId}, page}) => { + const element = await getByTestId('testid-text-input') - expect(await queries.getNodeText(element)).toEqual('Hello h1') - }) + expect(await page.evaluate(el => el.outerHTML, element)).toMatch( + ``, + ) + }) - test('handles page navigations', async ({queries: {getByText}, page}) => { - await page.goto(`file://${path.join(__dirname, '../fixtures/page.html')}`) + test('attaches `getNodeText`', async ({queries}) => { + const element = await queries.getByText('Hello h1') - const element = await getByText('Hello h1') + expect(await queries.getNodeText(element)).toEqual('Hello h1') + }) - expect(await element.textContent()).toEqual('Hello h1') - }) + test('handles page navigations', async ({queries: {getByText}, page}) => { + await page.goto(`file://${path.join(__dirname, '../fixtures/page.html')}`) - test('should handle the get* method failures', async ({queries}) => { - const {getByTitle} = queries - // Use the scoped element so the pretty HTML snapshot is smaller + const element = await getByText('Hello h1') - await expect(async () => getByTitle('missing')).rejects.toThrow() - }) + expect(await element.textContent()).toEqual('Hello h1') + }) - test('should handle the LabelText methods', async ({queries, page}) => { - const {getByLabelText} = queries - const element = await getByLabelText('Label A') - /* istanbul ignore next */ - expect(await page.evaluate(el => el.outerHTML, element)).toMatch( - ``, - ) - }) + test('should handle the get* method failures', async ({queries}) => { + const {getByTitle} = queries + // Use the scoped element so the pretty HTML snapshot is smaller - test('should handle the queryAll* methods', async ({queries, page}) => { - const {queryAllByText} = queries - const elements = await queryAllByText(/Hello/) - expect(elements).toHaveLength(3) + await expect(async () => getByTitle('missing')).rejects.toThrow() + }) - const text = await Promise.all([ - page.evaluate(el => el.textContent, elements[0]), - page.evaluate(el => el.textContent, elements[1]), - page.evaluate(el => el.textContent, elements[2]), - ]) + test('should handle the LabelText methods', async ({queries, page}) => { + const {getByLabelText} = queries + const element = await getByLabelText('Label A') + /* istanbul ignore next */ + expect(await page.evaluate(el => el.outerHTML, element)).toMatch( + ``, + ) + }) - expect(text).toEqual(['Hello h1', 'Hello h2', 'Hello h3']) - }) + test('should handle the queryAll* methods', async ({queries, page}) => { + const {queryAllByText} = queries + const elements = await queryAllByText(/Hello/) + expect(elements).toHaveLength(3) - test('should handle the queryAll* methods with a selector', async ({queries, page}) => { - const {queryAllByText} = queries - const elements = await queryAllByText(/Hello/, {selector: 'h2'}) - expect(elements).toHaveLength(1) + const text = await Promise.all([ + page.evaluate(el => el.textContent, elements[0]), + page.evaluate(el => el.textContent, elements[1]), + page.evaluate(el => el.textContent, elements[2]), + ]) - const text = await page.evaluate(el => el.textContent, elements[0]) + expect(text).toEqual(['Hello h1', 'Hello h2', 'Hello h3']) + }) - expect(text).toEqual('Hello h2') - }) + test('should handle the queryAll* methods with a selector', async ({queries, page}) => { + const {queryAllByText} = queries + const elements = await queryAllByText(/Hello/, {selector: 'h2'}) + expect(elements).toHaveLength(1) - test('should handle the getBy* methods with a selector', async ({queries, page}) => { - const {getByText} = queries - const element = await getByText(/Hello/, {selector: 'h2'}) + const text = await page.evaluate(el => el.textContent, elements[0]) - const text = await page.evaluate(el => el.textContent, element) + expect(text).toEqual('Hello h2') + }) - expect(text).toEqual('Hello h2') - }) + test('should handle the getBy* methods with a selector', async ({queries, page}) => { + const {getByText} = queries + const element = await getByText(/Hello/, {selector: 'h2'}) - test('should handle the getBy* methods with a regex name', async ({queries, page}) => { - const {getByRole} = queries - const element = await getByRole('button', {name: /getBy.*Test/}) + const text = await page.evaluate(el => el.textContent, element) - const text = await page.evaluate(el => el.textContent, element) + expect(text).toEqual('Hello h2') + }) - expect(text).toEqual('getByRole Test') - }) + test('should handle the getBy* methods with a regex name', async ({queries, page}) => { + const {getByRole} = queries + const element = await getByRole('button', {name: /getBy.*Test/}) - test('supports `hidden` option when querying by role', async ({queries: {queryAllByRole}}) => { - const elements = await queryAllByRole('img') - const hiddenElements = await queryAllByRole('img', {hidden: true}) + const text = await page.evaluate(el => el.textContent, element) - expect(elements).toHaveLength(1) - expect(hiddenElements).toHaveLength(2) - }) + expect(text).toEqual('getByRole Test') + }) - test.describe('querying by role with `level` option', () => { - test('retrieves the correct elements when querying all by role', async ({ - queries: {queryAllByRole}, - }) => { - const elements = await queryAllByRole('heading') - const levelOneElements = await queryAllByRole('heading', {level: 3}) + test('supports `hidden` option when querying by role', async ({queries: {queryAllByRole}}) => { + const elements = await queryAllByRole('img') + const hiddenElements = await queryAllByRole('img', {hidden: true}) - expect(elements).toHaveLength(3) - expect(levelOneElements).toHaveLength(1) + expect(elements).toHaveLength(1) + expect(hiddenElements).toHaveLength(2) }) - test('does not throw when querying for a specific element', async ({queries: {getByRole}}) => { - await expect(getByRole('heading', {level: 3})).resolves.not.toThrow() + test.describe('querying by role with `level` option', () => { + test('retrieves the correct elements when querying all by role', async ({ + queries: {queryAllByRole}, + }) => { + const elements = await queryAllByRole('heading') + const levelOneElements = await queryAllByRole('heading', {level: 3}) + + expect(elements).toHaveLength(3) + expect(levelOneElements).toHaveLength(1) + }) + + test('does not throw when querying for a specific element', async ({ + queries: {getByRole}, + }) => { + await expect(getByRole('heading', {level: 3})).resolves.not.toThrow() + }) }) - }) - test('should get text content', async ({page}) => { - const document = await getDocument(page) - const $h3 = await document.$('#scoped h3') + test('should get text content', async ({page}) => { + const document = await getDocument(page) + const $h3 = await document.$('#scoped h3') - expect(await $h3.textContent()).toEqual('Hello h3') - }) - - test('scoping queries with `within`', async ({queries: {getByTestId}}) => { - // eslint-disable-next-line @typescript-eslint/unbound-method - const {queryByText} = within(await getByTestId('scoped')) + expect(await $h3.textContent()).toEqual('Hello h3') + }) - expect(await queryByText('Hello h1')).toBeFalsy() - expect(await queryByText('Hello h3')).toBeTruthy() - }) + test('scoping queries with `within`', async ({queries: {getByTestId}}) => { + // eslint-disable-next-line @typescript-eslint/unbound-method + const {queryByText} = within(await getByTestId('scoped')) - test('scoping queries with `getQueriesForElement`', async ({queries: {getByTestId}}) => { - // eslint-disable-next-line @typescript-eslint/unbound-method - const {queryByText} = getQueriesForElement(await getByTestId('scoped')) + expect(await queryByText('Hello h1')).toBeFalsy() + expect(await queryByText('Hello h3')).toBeTruthy() + }) - expect(await queryByText('Hello h1')).toBeFalsy() - expect(await queryByText('Hello h3')).toBeTruthy() - }) + test('scoping queries with `getQueriesForElement`', async ({queries: {getByTestId}}) => { + // eslint-disable-next-line @typescript-eslint/unbound-method + const {queryByText} = getQueriesForElement(await getByTestId('scoped')) - test.describe('configuration', () => { - test.afterEach(() => { - configure({testIdAttribute: 'data-testid'}) // cleanup + expect(await queryByText('Hello h1')).toBeFalsy() + expect(await queryByText('Hello h3')).toBeTruthy() }) - test('should support custom data-testid attribute name', async ({queries}) => { - configure({testIdAttribute: 'data-id'}) + test.describe('configuration', () => { + test.afterEach(() => { + configure({testIdAttribute: 'data-testid'}) // cleanup + }) - const element = await queries.getByTestId('second-level-header') + test('should support custom data-testid attribute name', async ({queries}) => { + configure({testIdAttribute: 'data-id'}) - expect(await queries.getNodeText(element)).toEqual('Hello h2') - }) + const element = await queries.getByTestId('second-level-header') - test('should support subsequent changing the data-testid attribute names', async ({ - queries, - }) => { - configure({testIdAttribute: 'data-id'}) - configure({testIdAttribute: 'data-new-id'}) + expect(await queries.getNodeText(element)).toEqual('Hello h2') + }) - const element = await queries.getByTestId('first-level-header') + test('should support subsequent changing the data-testid attribute names', async ({ + queries, + }) => { + configure({testIdAttribute: 'data-id'}) + configure({testIdAttribute: 'data-new-id'}) - expect(await queries.getNodeText(element)).toEqual('Hello h1') + const element = await queries.getByTestId('first-level-header') + + expect(await queries.getNodeText(element)).toEqual('Hello h1') + }) }) }) + 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 {findByText} = queries expect(await findByText('Loaded!', {}, {timeout: 7000})).toBeTruthy() diff --git a/test/fixture/locators.test.ts b/test/fixture/locators.test.ts index 7dc56ea..7dbee45 100644 --- a/test/fixture/locators.test.ts +++ b/test/fixture/locators.test.ts @@ -13,156 +13,162 @@ const test = playwright.test.extend(fixtures) const {expect} = test test.describe('lib/fixture.ts (locators)', () => { - test.beforeEach(async ({page}) => { - await page.goto(`file://${path.join(__dirname, '../fixtures/page.html')}`) - }) + test.describe('standard page', () => { + test.beforeEach(async ({page}) => { + await page.goto(`file://${path.join(__dirname, '../fixtures/page.html')}`) + }) - test('should handle the query* methods', async ({queries: {queryByText}}) => { - const locator = queryByText('Hello h1') + test.afterEach(async ({page}) => page.close()) - expect(locator).toBeTruthy() - expect(await locator.textContent()).toEqual('Hello h1') - }) + test('should handle the query* methods', async ({queries: {queryByText}}) => { + const locator = queryByText('Hello h1') - test('should use the new v3 methods', async ({queries: {queryByRole}}) => { - const locator = queryByRole('presentation') - - expect(locator).toBeTruthy() - expect(await locator.textContent()).toContain('Layout table') - }) + expect(locator).toBeTruthy() + expect(await locator.textContent()).toEqual('Hello h1') + }) - test('should handle regex matching', async ({queries: {queryByText}}) => { - const locator = queryByText(/HeLlO h(1|7)/i) + test('should use the new v3 methods', async ({queries: {queryByRole}}) => { + const locator = queryByRole('presentation') - expect(locator).toBeTruthy() - expect(await locator.textContent()).toEqual('Hello h1') - }) + expect(locator).toBeTruthy() + expect(await locator.textContent()).toContain('Layout table') + }) - test('should handle the get* methods', async ({queries: {getByTestId}}) => { - const locator = getByTestId('testid-text-input') + test('should handle regex matching', async ({queries: {queryByText}}) => { + const locator = queryByText(/HeLlO h(1|7)/i) - expect(await locator.evaluate(el => el.outerHTML)).toMatch( - ``, - ) - }) + expect(locator).toBeTruthy() + expect(await locator.textContent()).toEqual('Hello h1') + }) - test('handles page navigations', async ({queries: {getByText}, page}) => { - await page.goto(`file://${path.join(__dirname, '../fixtures/page.html')}`) + test('should handle the get* methods', async ({queries: {getByTestId}}) => { + const locator = getByTestId('testid-text-input') - const locator = getByText('Hello h1') + expect(await locator.evaluate(el => el.outerHTML)).toMatch( + ``, + ) + }) - expect(await locator.textContent()).toEqual('Hello h1') - }) + test('handles page navigations', async ({queries: {getByText}, page}) => { + await page.goto(`file://${path.join(__dirname, '../fixtures/page.html')}`) - test('should handle the get* method failures', async ({queries}) => { - const {getByTitle} = queries - // Use the scoped element so the pretty HTML snapshot is smaller + const locator = getByText('Hello h1') - await expect(async () => getByTitle('missing').textContent()).rejects.toThrow() - }) + expect(await locator.textContent()).toEqual('Hello h1') + }) - test('should handle the LabelText methods', async ({queries}) => { - const {getByLabelText} = queries - const locator = getByLabelText('Label A') + test('should handle the get* method failures', async ({queries}) => { + const {getByTitle} = queries + // Use the scoped element so the pretty HTML snapshot is smaller - /* istanbul ignore next */ - expect(await locator.evaluate(el => el.outerHTML)).toMatch( - ``, - ) - }) + await expect(async () => getByTitle('missing').textContent()).rejects.toThrow() + }) - test('should handle the queryAll* methods', async ({queries}) => { - const {queryAllByText} = queries - const locator = queryAllByText(/Hello/) + test('should handle the LabelText methods', async ({queries}) => { + const {getByLabelText} = queries + const locator = getByLabelText('Label A') - expect(await locator.count()).toEqual(3) + /* istanbul ignore next */ + expect(await locator.evaluate(el => el.outerHTML)).toMatch( + ``, + ) + }) - const text = await Promise.all([ - locator.nth(0).textContent(), - locator.nth(1).textContent(), - locator.nth(2).textContent(), - ]) + test('should handle the queryAll* methods', async ({queries}) => { + const {queryAllByText} = queries + const locator = queryAllByText(/Hello/) - expect(text).toEqual(['Hello h1', 'Hello h2', 'Hello h3']) - }) + expect(await locator.count()).toEqual(3) - test('should handle the queryAll* methods with a selector', async ({queries}) => { - const {queryAllByText} = queries - const locator = queryAllByText(/Hello/, {selector: 'h2'}) + const text = await Promise.all([ + locator.nth(0).textContent(), + locator.nth(1).textContent(), + locator.nth(2).textContent(), + ]) - expect(await locator.count()).toEqual(1) + expect(text).toEqual(['Hello h1', 'Hello h2', 'Hello h3']) + }) - expect(await locator.textContent()).toEqual('Hello h2') - }) + test('should handle the queryAll* methods with a selector', async ({queries}) => { + const {queryAllByText} = queries + const locator = queryAllByText(/Hello/, {selector: 'h2'}) - test('should handle the getBy* methods with a selector', async ({queries}) => { - const {getByText} = queries - const locator = getByText(/Hello/, {selector: 'h2'}) + expect(await locator.count()).toEqual(1) - expect(await locator.textContent()).toEqual('Hello h2') - }) + expect(await locator.textContent()).toEqual('Hello h2') + }) - test('should handle the getBy* methods with a regex name', async ({queries}) => { - const {getByRole} = queries - const element = getByRole('button', {name: /getBy.*Test/}) + test('should handle the getBy* methods with a selector', async ({queries}) => { + const {getByText} = queries + const locator = getByText(/Hello/, {selector: 'h2'}) - expect(await element.textContent()).toEqual('getByRole Test') - }) + expect(await locator.textContent()).toEqual('Hello h2') + }) - test('supports `hidden` option when querying by role', async ({queries: {queryAllByRole}}) => { - const elements = queryAllByRole('img') - const hiddenElements = queryAllByRole('img', {hidden: true}) + test('should handle the getBy* methods with a regex name', async ({queries}) => { + const {getByRole} = queries + const element = getByRole('button', {name: /getBy.*Test/}) - expect(await elements.count()).toEqual(1) - expect(await hiddenElements.count()).toEqual(2) - }) + expect(await element.textContent()).toEqual('getByRole Test') + }) - test.describe('querying by role with `level` option', () => { - test('retrieves the correct elements when querying all by role', async ({ - queries: {queryAllByRole}, - }) => { - const locator = queryAllByRole('heading') - const levelOneLocator = queryAllByRole('heading', {level: 3}) + test('supports `hidden` option when querying by role', async ({queries: {queryAllByRole}}) => { + const elements = queryAllByRole('img') + const hiddenElements = queryAllByRole('img', {hidden: true}) - expect(await locator.count()).toEqual(3) - expect(await levelOneLocator.count()).toEqual(1) + expect(await elements.count()).toEqual(1) + expect(await hiddenElements.count()).toEqual(2) }) - test('does not throw when querying for a specific element', async ({queries: {getByRole}}) => { - await expect(getByRole('heading', {level: 3}).textContent()).resolves.not.toThrow() + test.describe('querying by role with `level` option', () => { + test('retrieves the correct elements when querying all by role', async ({ + queries: {queryAllByRole}, + }) => { + const locator = queryAllByRole('heading') + const levelOneLocator = queryAllByRole('heading', {level: 3}) + + expect(await locator.count()).toEqual(3) + expect(await levelOneLocator.count()).toEqual(1) + }) + + test('does not throw when querying for a specific element', async ({ + queries: {getByRole}, + }) => { + await expect(getByRole('heading', {level: 3}).textContent()).resolves.not.toThrow() + }) }) - }) - test('scopes to container with `within`', async ({queries: {queryByRole}}) => { - const form = queryByRole('form', {name: 'User'}) + test('scopes to container with `within`', async ({queries: {queryByRole}}) => { + const form = queryByRole('form', {name: 'User'}) - const {queryByLabelText} = within(form) + const {queryByLabelText} = within(form) - const outerLocator = queryByLabelText('Name') - const innerLocator = queryByLabelText('Username') + const outerLocator = queryByLabelText('Name') + const innerLocator = queryByLabelText('Username') - expect(await outerLocator.count()).toBe(0) - expect(await innerLocator.count()).toBe(1) - }) + expect(await outerLocator.count()).toBe(0) + expect(await innerLocator.count()).toBe(1) + }) - test.describe('configuration', () => { - test.describe('custom data-testeid', () => { - test.use({testIdAttribute: 'data-id'}) + test.describe('configuration', () => { + test.describe('custom data-testid', () => { + test.use({testIdAttribute: 'data-id'}) - test('supports custom data-testid attribute name', async ({queries}) => { - const locator = queries.getByTestId('second-level-header') + test('supports custom data-testid attribute name', async ({queries}) => { + const locator = queries.getByTestId('second-level-header') - expect(await locator.textContent()).toEqual('Hello h2') + expect(await locator.textContent()).toEqual('Hello h2') + }) }) - }) - test.describe('nested configuration', () => { - test.use({testIdAttribute: 'data-new-id'}) + test.describe('nested configuration', () => { + test.use({testIdAttribute: 'data-new-id'}) - test('supports nested data-testid attribute names', async ({queries}) => { - const locator = queries.getByTestId('first-level-header') + test('supports nested data-testid attribute names', async ({queries}) => { + const locator = queries.getByTestId('first-level-header') - expect(await locator.textContent()).toEqual('Hello h1') + expect(await locator.textContent()).toEqual('Hello h1') + }) }) }) }) From c34812b2c22cc21c7a261cc7fc3b89111468b00e Mon Sep 17 00:00:00 2001 From: Jamie Rolfs Date: Wed, 31 Aug 2022 21:27:13 -0700 Subject: [PATCH 10/21] 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 23be226fa8922d3c161a9b4c5c6f6119f023714a Mon Sep 17 00:00:00 2001 From: Jamie Rolfs Date: Fri, 2 Sep 2022 18:09:40 -0700 Subject: [PATCH 11/21] 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 cff36b57d1b09a6578659dbce76be55344dbb38d Mon Sep 17 00:00:00 2001 From: Jamie Rolfs Date: Fri, 2 Sep 2022 18:15:07 -0700 Subject: [PATCH 12/21] 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 4b8cdbbc972c4bae77c8728cbdd79bc1ea566d25 Mon Sep 17 00:00:00 2001 From: Jamie Rolfs Date: Fri, 2 Sep 2022 18:15:42 -0700 Subject: [PATCH 13/21] 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 From 72255c65aae1552c7d35ef3c60bb4709e84ff766 Mon Sep 17 00:00:00 2001 From: Jamie Rolfs Date: Wed, 7 Sep 2022 00:58:35 -0700 Subject: [PATCH 14/21] Revert "build(deps-dev): bump generate-export-aliases from 1.1.0 to 1.2.0" This reverts commit c5b14dd786bdc0f8a4bc791cac2ae50d4ee7ad6f. --- package-lock.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index fa1820d..cc267d2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8053,9 +8053,9 @@ } }, "node_modules/generate-export-aliases": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/generate-export-aliases/-/generate-export-aliases-1.2.0.tgz", - "integrity": "sha512-G83jrsIOerqkClvlD7vyaUnbVBYQbqUnuyVMG/KSfzi4wShtohlECBJZIgmGrv7TiOWNr8o/4g0QOSPsQmwcUw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/generate-export-aliases/-/generate-export-aliases-1.1.0.tgz", + "integrity": "sha512-cMQP1XKgEXbPJNilAQ35ivH53QYqWsC5vJGxTSIruz70iv3bfCKeIXcmo4gQs9zVS2nMnsPKIrrO+vxBvYhhAw==", "dev": true, "dependencies": { "bluebird": "^3.4.4", @@ -22514,9 +22514,9 @@ "dev": true }, "generate-export-aliases": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/generate-export-aliases/-/generate-export-aliases-1.2.0.tgz", - "integrity": "sha512-G83jrsIOerqkClvlD7vyaUnbVBYQbqUnuyVMG/KSfzi4wShtohlECBJZIgmGrv7TiOWNr8o/4g0QOSPsQmwcUw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/generate-export-aliases/-/generate-export-aliases-1.1.0.tgz", + "integrity": "sha512-cMQP1XKgEXbPJNilAQ35ivH53QYqWsC5vJGxTSIruz70iv3bfCKeIXcmo4gQs9zVS2nMnsPKIrrO+vxBvYhhAw==", "dev": true, "requires": { "bluebird": "^3.4.4", From 0e24e8f6fcb56dfe6942f814d7e9171251752c2d Mon Sep 17 00:00:00 2001 From: Jamie Rolfs Date: Wed, 7 Sep 2022 23:02:30 -0700 Subject: [PATCH 15/21] test: speed up tests by only delaying 'late page' by 2 seconds --- test/fixture/element-handles.test.ts | 4 ++-- test/fixture/locators.test.ts | 18 +++++++++--------- test/fixtures/late-page.html | 2 +- test/standalone/index.test.ts | 2 +- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/test/fixture/element-handles.test.ts b/test/fixture/element-handles.test.ts index bd74084..c674e99 100644 --- a/test/fixture/element-handles.test.ts +++ b/test/fixture/element-handles.test.ts @@ -202,12 +202,12 @@ test.describe('lib/fixture.ts', () => { test('should handle the findBy* methods', async ({queries}) => { const {findByText} = queries - expect(await findByText('Loaded!', {}, {timeout: 7000})).toBeTruthy() + expect(await findByText('Loaded!', {}, {timeout: 3000})).toBeTruthy() }) test('should handle the findByAll* methods', async ({queries}) => { const {findAllByText} = queries - const elements = await findAllByText(/Hello/, {}, {timeout: 7000}) + const elements = await findAllByText(/Hello/, {}, {timeout: 3000}) expect(elements).toHaveLength(2) const text = await Promise.all([elements[0].textContent(), elements[1].textContent()]) diff --git a/test/fixture/locators.test.ts b/test/fixture/locators.test.ts index 5b4cef9..22daf8e 100644 --- a/test/fixture/locators.test.ts +++ b/test/fixture/locators.test.ts @@ -180,13 +180,13 @@ test.describe('lib/fixture.ts (locators)', () => { test.afterEach(async ({page}) => page.close()) test('should handle the findBy* methods', async ({queries}) => { - const locator = await queries.findByText('Loaded!', undefined, {timeout: 7000}) + const locator = await queries.findByText('Loaded!', undefined, {timeout: 3000}) expect(await locator.textContent()).toEqual('Loaded!') }) test('should handle the findAllBy* methods', async ({queries}) => { - const locator = await queries.findAllByText(/Hello/, undefined, {timeout: 7000}) + const locator = await queries.findAllByText(/Hello/, undefined, {timeout: 3000}) const text = await Promise.all([locator.nth(0).textContent(), locator.nth(1).textContent()]) @@ -194,7 +194,7 @@ test.describe('lib/fixture.ts (locators)', () => { }) test('throws Testing Library error when locator times out', async ({queries}) => { - const query = async () => queries.findByText(/Loaded!/, undefined, {timeout: 1000}) + const query = async () => queries.findByText(/Loaded!/, undefined, {timeout: 500}) await expect(query).rejects.toThrowError( expect.objectContaining({ @@ -204,7 +204,7 @@ test.describe('lib/fixture.ts (locators)', () => { }) test('throws Testing Library error when multi-element locator times out', async ({queries}) => { - const query = async () => queries.findAllByText(/Hello/, undefined, {timeout: 1000}) + const query = async () => queries.findAllByText(/Hello/, undefined, {timeout: 500}) await expect(query).rejects.toThrowError( expect.objectContaining({ @@ -214,7 +214,7 @@ test.describe('lib/fixture.ts (locators)', () => { }) test.describe('configuring asynchronous queries via `use`', () => { - test.use({asyncUtilTimeout: 7000}) + test.use({asyncUtilTimeout: 3000}) 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 @@ -232,7 +232,7 @@ test.describe('lib/fixture.ts (locators)', () => { await expect(queries.getByText('Hidden')).toBeHidden() const locator = await queries.findByText('Hidden', undefined, { - timeout: 7000, + timeout: 3000, state: 'visible', }) @@ -245,7 +245,7 @@ test.describe('lib/fixture.ts (locators)', () => { 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}) + const locator = await queries.findByText('Hidden', undefined, {timeout: 3000}) expect(await locator.textContent()).toEqual('Hidden') }) @@ -257,7 +257,7 @@ test.describe('lib/fixture.ts (locators)', () => { await expect(queries.queryByText('Attached')).toHaveCount(0) const locator = await queries.findByText('Attached', undefined, { - timeout: 7000, + timeout: 3000, state: 'attached', }) @@ -271,7 +271,7 @@ test.describe('lib/fixture.ts (locators)', () => { 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}) + const locator = await queries.findByText('Attached', undefined, {timeout: 3000}) 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 8c4077e..8622bae 100644 --- a/test/fixtures/late-page.html +++ b/test/fixtures/late-page.html @@ -24,7 +24,7 @@ attached.textContent = 'Attached' attached.style.visibility = 'hidden' document.body.appendChild(attached) - }, 5000) + }, 2000) diff --git a/test/standalone/index.test.ts b/test/standalone/index.test.ts index 37ae2af..e1609e3 100644 --- a/test/standalone/index.test.ts +++ b/test/standalone/index.test.ts @@ -179,7 +179,7 @@ describe('lib/index.ts', () => { afterEach(async () => page.goto(`file://${path.join(__dirname, '../fixtures/page.html')}`)) it('supports configuring timeout for findBy* queries', async () => { - configure({asyncUtilTimeout: 9000}) + configure({asyncUtilTimeout: 3000}) const element = await queries.findByText(await getDocument(page), 'Loaded!') From b2d20a6c2cb42840a2170d4bdb24b6af53acf534 Mon Sep 17 00:00:00 2001 From: Jamie Rolfs Date: Wed, 7 Sep 2022 23:40:08 -0700 Subject: [PATCH 16/21] refactor(fixture): split query-related stuff out into separate module --- lib/fixture/locator/fixtures.ts | 9 +-- lib/fixture/locator/helpers.ts | 102 +------------------------------- lib/fixture/locator/queries.ts | 100 +++++++++++++++++++++++++++++++ 3 files changed, 105 insertions(+), 106 deletions(-) create mode 100644 lib/fixture/locator/queries.ts diff --git a/lib/fixture/locator/fixtures.ts b/lib/fixture/locator/fixtures.ts index 09ff236..cbd132f 100644 --- a/lib/fixture/locator/fixtures.ts +++ b/lib/fixture/locator/fixtures.ts @@ -9,13 +9,8 @@ import type { Within, } from '../types' -import { - buildTestingLibraryScript, - isAllQuery, - queriesFor, - queryToSelector, - synchronousQueryNames, -} from './helpers' +import {buildTestingLibraryScript, queryToSelector} from './helpers' +import {isAllQuery, queriesFor, synchronousQueryNames} from './queries' type TestArguments = PlaywrightTestArgs & Config diff --git a/lib/fixture/locator/helpers.ts b/lib/fixture/locator/helpers.ts index ff12d69..3304197 100644 --- a/lib/fixture/locator/helpers.ts +++ b/lib/fixture/locator/helpers.ts @@ -1,33 +1,8 @@ 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' -import {replacer, reviver} from '../helpers' -import type { - AllQuery, - Config, - FindQuery, - GetQuery, - LocatorQueries as Queries, - Query, - QueryQuery, - Selector, - SynchronousQuery, -} from '../types' - -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 +import {reviver} from '../helpers' +import type {Config, Selector, SynchronousQuery} from '../types' const queryToSelector = (query: SynchronousQuery) => query.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase() as Selector @@ -47,75 +22,4 @@ const buildTestingLibraryScript = async ({config}: {config: Config}) => { ` } -const synchronousQueryNames = allQueryNames.filter(isNotFindQuery) - -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 - } - -/** - * 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) => ({ - ...rest, - [query]: isFindQuery(query) - ? createFindQuery(pageOrLocator, query, config) - : (...args: Parameters) => - pageOrLocator.locator(`${queryToSelector(query)}=${JSON.stringify(args, replacer)}`), - }), - {} as Queries, - ) - -export {buildTestingLibraryScript, isAllQuery, queriesFor, queryToSelector, synchronousQueryNames} +export {buildTestingLibraryScript, queryToSelector} diff --git a/lib/fixture/locator/queries.ts b/lib/fixture/locator/queries.ts new file mode 100644 index 0000000..0a9b2db --- /dev/null +++ b/lib/fixture/locator/queries.ts @@ -0,0 +1,100 @@ +import type {Locator, Page} from '@playwright/test' +import {errors} from '@playwright/test' +import {queries} from '@testing-library/dom' + +import {replacer} from '../helpers' +import type { + AllQuery, + Config, + FindQuery, + GetQuery, + LocatorQueries as Queries, + Query, + QueryQuery, + SynchronousQuery, +} from '../types' + +import {queryToSelector} from './helpers' + +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 allQueryNames = Object.keys(queries) as Query[] +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 = 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 + } + +/** + * 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) => ({ + ...rest, + [query]: isFindQuery(query) + ? createFindQuery(pageOrLocator, query, config) + : (...args: Parameters) => + pageOrLocator.locator(`${queryToSelector(query)}=${JSON.stringify(args, replacer)}`), + }), + {} as Queries, + ) + +export {allQueryNames, isAllQuery, isNotFindQuery, queriesFor, synchronousQueryNames} From 05021a7e183142c296d2b875e481d3e94221c22f Mon Sep 17 00:00:00 2001 From: Jamie Rolfs Date: Thu, 8 Sep 2022 00:32:05 -0700 Subject: [PATCH 17/21] =?UTF-8?q?ci(actions):=20oops,=20actually=20check?= =?UTF-8?q?=20types=20=F0=9F=A4=A6=F0=9F=8F=BB=E2=80=8D=E2=99=82=EF=B8=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/build.yml | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a9b240d..6050de1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -13,12 +13,16 @@ on: jobs: build: - name: Build + Test + Release / Node ${{ matrix.node }} + name: Build + Test + Release / Node ${{ matrix.node }} / playwright@${{ matrix.playwright }} runs-on: ubuntu-latest strategy: matrix: node: ['12', '14', '16'] - playwright: ['1.12.0', 'latest'] + # TODO: technically we still support down to 1.12 but `locator.waitFor` + # was introduced in 1.16 so anything earlier blows up type-checking. + # This minimum will be bumped in the next breaking release that will be + # entirely built around the `Locator` APIs, so update this then. + playwright: ['1.16.0', 'latest'] steps: - name: Checkout @@ -47,14 +51,17 @@ jobs: npm install playwright@${{ matrix.playwright }} npm install @playwright/test@${{ matrix.playwright }} - - name: Check types, run lint + tests + - name: Check types + run: npm run test:types + + - name: Run lint + tests if: ${{ matrix.playwright == 'latest' }} run: | npm why playwright npm why @playwright/test npm run test - - name: Check types, run lint + tests + - name: Run lint + tests if: ${{ matrix.playwright != 'latest' }} run: | npm why playwright From d4611afe2ef2a136f9740b1a49632fa55f6d070e Mon Sep 17 00:00:00 2001 From: Jamie Rolfs Date: Thu, 8 Sep 2022 00:33:24 -0700 Subject: [PATCH 18/21] chore(fixture): fix unofficial `queriesFor` export --- lib/fixture/locator/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/fixture/locator/index.ts b/lib/fixture/locator/index.ts index f5b4dd3..839a2de 100644 --- a/lib/fixture/locator/index.ts +++ b/lib/fixture/locator/index.ts @@ -6,4 +6,4 @@ export { withinFixture, } from './fixtures' export type {Queries} from './fixtures' -export {queriesFor} from './helpers' +export {queriesFor} from './queries' From 348344b814ae7dcfa788086fc5e1b646617b1400 Mon Sep 17 00:00:00 2001 From: Jamie Rolfs Date: Thu, 8 Sep 2022 00:06:25 -0700 Subject: [PATCH 19/21] fix(fixture): throw correct error when `find*` query times out on visibility --- lib/fixture/locator/queries.ts | 20 ++++++++++++++++---- test/fixture/locators.test.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/lib/fixture/locator/queries.ts b/lib/fixture/locator/queries.ts index 0a9b2db..63f480e 100644 --- a/lib/fixture/locator/queries.ts +++ b/lib/fixture/locator/queries.ts @@ -44,17 +44,18 @@ const createFindQuery = )}`, ) - const {state = asyncUtilExpectedState, timeout = asyncUtilTimeout} = waitForElementOptions ?? {} + const {state: expectedState = asyncUtilExpectedState, timeout = asyncUtilTimeout} = + waitForElementOptions ?? {} try { - await locator.first().waitFor({state, timeout}) + 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) { - return pageOrLocator + const timeoutLocator = pageOrLocator .locator( `${queryToSelector(findQueryToGetQuery(query))}=${JSON.stringify( synchronousOptions, @@ -62,7 +63,18 @@ const createFindQuery = )}`, ) .first() - .waitFor({state, timeout: 100}) + + // 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 + return timeoutLocator.waitFor({state: expectedState, timeout}) } throw error diff --git a/test/fixture/locators.test.ts b/test/fixture/locators.test.ts index 22daf8e..0db6df2 100644 --- a/test/fixture/locators.test.ts +++ b/test/fixture/locators.test.ts @@ -203,6 +203,32 @@ test.describe('lib/fixture.ts (locators)', () => { ) }) + test('throws Playwright error when locator times out for visible state (but is attached)', async ({ + queries, + }) => { + const query = async () => + queries.findByText(/Hidden/, undefined, {state: 'visible', timeout: 500}) + + await expect(query).rejects.toThrowError( + expect.objectContaining({ + message: expect.stringContaining('500'), + }), + ) + }) + + test('throws Testing Library error when locator times out for attached state', async ({ + queries, + }) => { + const query = async () => + queries.findByText(/Loaded!/, undefined, {state: 'attached', timeout: 500}) + + 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: 500}) From d795d4e13ffd9d205b11319c79b3ca5961836ce9 Mon Sep 17 00:00:00 2001 From: Jamie Rolfs Date: Thu, 8 Sep 2022 00:41:38 -0700 Subject: [PATCH 20/21] feat(fixture): add `screen` fixture that combines `Page` and `Queries` This will likely replace the `queries` fixture when the `Locator` fixture stuff is officially released --- lib/fixture/index.ts | 7 +++++-- lib/fixture/locator/fixtures.ts | 24 ++++++++++++++++++++++-- lib/fixture/locator/helpers.ts | 27 ++++++++++++++++++++++++++- lib/fixture/locator/index.ts | 1 + lib/fixture/types.ts | 4 +++- test/fixture/locators.test.ts | 10 ++++++++++ 6 files changed, 67 insertions(+), 6 deletions(-) diff --git a/lib/fixture/index.ts b/lib/fixture/index.ts index 7eb3c42..e5e54fd 100644 --- a/lib/fixture/index.ts +++ b/lib/fixture/index.ts @@ -2,21 +2,23 @@ import {Fixtures} from '@playwright/test' import type {Queries as ElementHandleQueries} from './element-handle' import {queriesFixture as elementHandleQueriesFixture} from './element-handle' -import type {Queries as LocatorQueries} from './locator' import { + Queries as LocatorQueries, installTestingLibraryFixture, queriesFixture as locatorQueriesFixture, options, queriesFor, registerSelectorsFixture, + screenFixture, withinFixture, } from './locator' -import type {Config} from './types' +import type {Config, Screen} from './types' import {Within} from './types' const elementHandleFixtures: Fixtures = {queries: elementHandleQueriesFixture} const locatorFixtures: Fixtures = { queries: locatorQueriesFixture, + screen: screenFixture, within: withinFixture, registerSelectors: registerSelectorsFixture, installTestingLibrary: installTestingLibraryFixture, @@ -29,6 +31,7 @@ interface ElementHandleFixtures { interface LocatorFixtures extends Partial { queries: LocatorQueries + screen: Screen within: Within registerSelectors: void installTestingLibrary: void diff --git a/lib/fixture/locator/fixtures.ts b/lib/fixture/locator/fixtures.ts index cbd132f..bafd5b5 100644 --- a/lib/fixture/locator/fixtures.ts +++ b/lib/fixture/locator/fixtures.ts @@ -4,13 +4,14 @@ import {selectors} from '@playwright/test' import type { Config, LocatorQueries as Queries, + Screen, SelectorEngine, SynchronousQuery, Within, } from '../types' -import {buildTestingLibraryScript, queryToSelector} from './helpers' -import {isAllQuery, queriesFor, synchronousQueryNames} from './queries' +import {buildTestingLibraryScript, includes, queryToSelector} from './helpers' +import {allQueryNames, isAllQuery, queriesFor, synchronousQueryNames} from './queries' type TestArguments = PlaywrightTestArgs & Config @@ -29,6 +30,24 @@ const queriesFixture: TestFixture = async ( use, ) => use(queriesFor(page, {asyncUtilExpectedState, asyncUtilTimeout})) +const screenFixture: TestFixture = async ( + {page, asyncUtilExpectedState, asyncUtilTimeout}, + use, +) => { + const queries = queriesFor(page, {asyncUtilExpectedState, asyncUtilTimeout}) + const revocable = Proxy.revocable(page, { + get(target, property, receiver) { + return includes(allQueryNames, property) + ? queries[property] + : Reflect.get(target, property, receiver) + }, + }) + + await use(revocable.proxy as Screen) + + revocable.revoke() +} + const withinFixture: TestFixture = async ( {asyncUtilExpectedState, asyncUtilTimeout}, use, @@ -117,6 +136,7 @@ export { options, queriesFixture, registerSelectorsFixture, + screenFixture, withinFixture, } export type {Queries} diff --git a/lib/fixture/locator/helpers.ts b/lib/fixture/locator/helpers.ts index 3304197..5e4914a 100644 --- a/lib/fixture/locator/helpers.ts +++ b/lib/fixture/locator/helpers.ts @@ -22,4 +22,29 @@ const buildTestingLibraryScript = async ({config}: {config: Config}) => { ` } -export {buildTestingLibraryScript, queryToSelector} +/** + * Alternative version of `Array.prototype.includes` that allows testing for + * the existence of an item with a type that is a _superset_ of the type of the + * items in the array. + * + * This allows us to use it to check whether an item of type `string` exists in + * an array of string literals (e.g: `['foo', 'bar'] as const`) without TypeScript + * complaining. It will, however, throw a compiler error if you try to pass an item + * of type `number`. + * + * @example + * const things = ['foo', 'bar'] as const; + * + * // error + * const hasThing = (t: string) => things.includes(t); + * + * // compiles + * const hasThing = (t: string) => includes(things, t); + * + * @param array array to search + * @param item item to search for + */ +const includes = (array: ReadonlyArray, item: U): item is T => + array.includes(item as T) + +export {buildTestingLibraryScript, includes, queryToSelector} diff --git a/lib/fixture/locator/index.ts b/lib/fixture/locator/index.ts index 839a2de..c07e4ac 100644 --- a/lib/fixture/locator/index.ts +++ b/lib/fixture/locator/index.ts @@ -3,6 +3,7 @@ export { options, queriesFixture, registerSelectorsFixture, + screenFixture, withinFixture, } from './fixtures' export type {Queries} from './fixtures' diff --git a/lib/fixture/types.ts b/lib/fixture/types.ts index 80b7225..61637cb 100644 --- a/lib/fixture/types.ts +++ b/lib/fixture/types.ts @@ -1,4 +1,4 @@ -import {Locator} from '@playwright/test' +import {Locator, Page} from '@playwright/test' import type * as TestingLibraryDom from '@testing-library/dom' import {queries} from '@testing-library/dom' @@ -51,7 +51,9 @@ type KebabCase = S extends `${infer C}${infer T}` : S export type LocatorQueries = {[K in keyof Queries]: ConvertQuery} + export type Within = (locator: Locator) => LocatorQueries +export type Screen = LocatorQueries & Page export type Query = keyof Queries diff --git a/test/fixture/locators.test.ts b/test/fixture/locators.test.ts index 0db6df2..9f87c91 100644 --- a/test/fixture/locators.test.ts +++ b/test/fixture/locators.test.ts @@ -170,6 +170,16 @@ test.describe('lib/fixture.ts (locators)', () => { }) }) }) + + test('screen fixture responds to Page and Query methods', async ({screen}) => { + const locator = screen.getByRole('button', {name: /getBy.*Test/}) + expect(await locator.textContent()).toEqual('getByRole Test') + + await screen.goto(`file://${path.join(__dirname, '../fixtures/late-page.html')}`) + + const delayedLocator = await screen.findByText('Loaded!', undefined, {timeout: 3000}) + expect(await delayedLocator.textContent()).toEqual('Loaded!') + }) }) test.describe('deferred page', () => { From 3ea0d267d6d6ea3b61118af17eb2265f70fbab07 Mon Sep 17 00:00:00 2001 From: Jamie Rolfs Date: Sat, 10 Sep 2022 15:05:08 -0700 Subject: [PATCH 21/21] feat(fixture): support query chaining locator queries --- lib/fixture/locator/fixtures.ts | 6 +- lib/fixture/locator/index.ts | 4 +- lib/fixture/locator/queries.ts | 176 +++++++++++++++++++++----------- lib/fixture/types.ts | 31 ++++-- test/fixture/locators.test.ts | 39 +++++++ test/fixtures/chaining.html | 39 +++++++ 6 files changed, 222 insertions(+), 73 deletions(-) create mode 100644 test/fixtures/chaining.html diff --git a/lib/fixture/locator/fixtures.ts b/lib/fixture/locator/fixtures.ts index bafd5b5..2f2a301 100644 --- a/lib/fixture/locator/fixtures.ts +++ b/lib/fixture/locator/fixtures.ts @@ -35,7 +35,7 @@ const screenFixture: TestFixture = async ( use, ) => { const queries = queriesFor(page, {asyncUtilExpectedState, asyncUtilTimeout}) - const revocable = Proxy.revocable(page, { + const {proxy, revoke} = Proxy.revocable(page, { get(target, property, receiver) { return includes(allQueryNames, property) ? queries[property] @@ -43,9 +43,9 @@ const screenFixture: TestFixture = async ( }, }) - await use(revocable.proxy as Screen) + await use(proxy as Screen) - revocable.revoke() + revoke() } const withinFixture: TestFixture = async ( 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 63f480e..6c08922 100644 --- a/lib/fixture/locator/queries.ts +++ b/lib/fixture/locator/queries.ts @@ -1,21 +1,28 @@ -import type {Locator, Page} from '@playwright/test' -import {errors} from '@playwright/test' +import {Locator, errors} from '@playwright/test' import {queries} from '@testing-library/dom' import {replacer} from '../helpers' import type { AllQuery, Config, + DeferredLocatorQueries as DeferredQueries, FindQuery, GetQuery, LocatorQueries as Queries, Query, QueryQuery, + QueryRoot, SynchronousQuery, + TestingLibraryLocator, } from '../types' import {queryToSelector} from './helpers' +type QueriesReturn = Root extends LocatorPromise ? DeferredQueries : Queries +// type LocatorReturn = Locator & {within: () => QueriesReturn} + +type SynchronousQueryParameters = Parameters + const isAllQuery = (query: Query): query is AllQuery => query.includes('All') const isFindQuery = (query: Query): query is FindQuery => query.startsWith('find') @@ -28,60 +35,95 @@ 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 - } - - // 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}) - } +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) { + return (...args: A) => LocatorPromise.from(fn(...args)) + } - throw error - } + static from(promise: Promise) { + return new LocatorPromise((resolve, reject) => { + promise.then(resolve).catch(reject) + }) + } - return locator + within() { + // eslint-disable-next-line @typescript-eslint/no-use-before-define + return queriesFor(this) } +} + +const locatorFor = ( + root: Exclude>, + query: SynchronousQuery, + options: SynchronousQueryParameters, +) => root.locator(`${queryToSelector(query)}=${JSON.stringify(options, replacer)}`) + +const augmentedLocatorFor = (...args: Parameters) => { + const locator = locatorFor(...args) + + return new Proxy(locator, { + get(target, property, receiver) { + return property === 'within' + ? // eslint-disable-next-line @typescript-eslint/no-use-before-define + () => queriesFor(target) + : 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 + }, + ) /** * Given a `Page` or `Locator` instance, return an object of Testing Library @@ -92,21 +134,35 @@ 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), }), - {} as Queries, + {} as QueriesReturn, ) -export {allQueryNames, isAllQuery, isNotFindQuery, queriesFor, synchronousQueryNames} +export { + allQueryNames, + createFindQuery, + isAllQuery, + isFindQuery, + isNotFindQuery, + queriesFor, + synchronousQueryNames, + LocatorPromise, +} diff --git a/lib/fixture/types.ts b/lib/fixture/types.ts index 61637cb..c436406 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,22 +50,26 @@ 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} +type ConvertQueryDeferred = Query extends ( + ...rest: infer Rest +) => any + ? (...args: Rest) => LocatorPromise + : never + +export type DeferredLocatorQueries = { + [K in keyof LocatorQueries]: ConvertQueryDeferred +} + +export type QueryRoot = Page | Locator | LocatorPromise export type Within = (locator: Locator) => LocatorQueries export type Screen = LocatorQueries & Page export type Query = keyof Queries - export type AllQuery = Extract export type FindQuery = Extract export type GetQuery = Extract diff --git a/test/fixture/locators.test.ts b/test/fixture/locators.test.ts index 9f87c91..7d29476 100644 --- a/test/fixture/locators.test.ts +++ b/test/fixture/locators.test.ts @@ -314,4 +314,43 @@ 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 asynchronous query', async ({screen}) => { + const locator = await screen + .findByRole('dialog') + .within() + .getByTestId('image-container') + .within() + .getByRole('img') + + expect(await locator.getAttribute('alt')).toEqual('Some image') + }) + }) }) diff --git a/test/fixtures/chaining.html b/test/fixtures/chaining.html new file mode 100644 index 0000000..f561e71 --- /dev/null +++ b/test/fixtures/chaining.html @@ -0,0 +1,39 @@ + + + +
+
Loading...
+
Some image
+
+ + +