From 1e75b4e2f992f5b771a1dcd6f7dcd495070b125f Mon Sep 17 00:00:00 2001 From: Jamie Rolfs Date: Wed, 13 Apr 2022 11:58:28 -0700 Subject: [PATCH] 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 3eac2fe..1826243 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 aaa4895..dea0a86 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" > + +
+ + +
+ + +
+ + +