Skip to content
This repository was archived by the owner on Aug 6, 2025. It is now read-only.

Commit d0e234b

Browse files
committed
wip: feat: add separate fixtures export with queries that return locators
1 parent 3d550d1 commit d0e234b

File tree

8 files changed

+349
-3
lines changed

8 files changed

+349
-3
lines changed

.eslintrc.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,5 +25,12 @@ module.exports = {
2525
'jest/no-done-callback': 'off',
2626
},
2727
},
28+
{
29+
files: ['lib/fixture/**/*.+(js|ts)'],
30+
rules: {
31+
'no-empty-pattern': 'off',
32+
'no-underscore-dangle': ['error', {allow: ['__testingLibraryReviver']}],
33+
},
34+
},
2835
],
2936
}

.github/workflows/build.yml

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,18 @@ jobs:
4848
npm install @playwright/test@${{ matrix.playwright }}
4949
5050
- name: Check types, run lint + tests
51+
if: ${{ matrix.playwright == 'latest' }}
5152
run: |
5253
npm why playwright
5354
npm why @playwright/test
54-
npm run validate
55+
npm run test
56+
57+
- name: Check types, run lint + tests
58+
if: ${{ matrix.playwright != 'latest' }}
59+
run: |
60+
npm why playwright
61+
npm why @playwright/test
62+
npm run test:legacy
5563
5664
# Only release on Node 14
5765

lib/fixture/helpers.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
const replacer = (_: string, value: unknown) => {
2+
if (value instanceof RegExp) return `__REGEXP ${value.toString()}`
3+
4+
return value
5+
}
6+
7+
const reviver = (_: string, value: string) => {
8+
if (value.toString().includes('__REGEXP ')) {
9+
const match = /\/(.*)\/(.*)?/.exec(value.split('__REGEXP ')[1])
10+
11+
return new RegExp(match![1], match![2] || '')
12+
}
13+
14+
return value
15+
}
16+
17+
export {replacer, reviver}

lib/fixture/index.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,36 @@ import {
33
Queries as ElementHandleQueries,
44
queriesFixture as elementHandleQueriesFixture,
55
} from './element-handle'
6+
import {
7+
Queries as LocatorQueries,
8+
queriesFixture as locatorQueriesFixture,
9+
registerSelectorsFixture,
10+
installTestingLibraryFixture,
11+
} from './locator'
612

713
const elementHandleFixtures: Fixtures = {queries: elementHandleQueriesFixture}
14+
const locatorFixtures: Fixtures = {
15+
queries: locatorQueriesFixture,
16+
registerSelectors: registerSelectorsFixture,
17+
installTestingLibrary: installTestingLibraryFixture,
18+
}
819

920
interface ElementHandleFixtures {
1021
queries: ElementHandleQueries
1122
}
1223

24+
interface LocatorFixtures {
25+
queries: LocatorQueries
26+
registerSelectors: void
27+
installTestingLibrary: void
28+
}
29+
1330
export type {ElementHandleFixtures as TestingLibraryFixtures}
1431
export {elementHandleQueriesFixture as fixture}
1532
export {elementHandleFixtures as fixtures}
1633

34+
export type {LocatorFixtures}
35+
export {locatorQueriesFixture}
36+
export {locatorFixtures}
37+
1738
export {configure} from '..'

lib/fixture/locator.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import {promises as fs} from 'fs'
2+
3+
import type {PlaywrightTestArgs, TestFixture} from '@playwright/test'
4+
import {selectors} from '@playwright/test'
5+
6+
import {queryNames} from '../common'
7+
import type {LocatorQueries as Queries, AllQuery, Query, SelectorEngine} from './types'
8+
import {replacer, reviver} from './helpers'
9+
10+
const queriesFixture: TestFixture<Queries, PlaywrightTestArgs> = async ({page}, use) => {
11+
const queries = queryNames.reduce(
12+
(rest, query) => ({
13+
...rest,
14+
[query]: (...args: Parameters<Queries[keyof Queries]>) =>
15+
page.locator(`__testing_library__=${JSON.stringify({query, args}, replacer)}`),
16+
}),
17+
{} as Queries,
18+
)
19+
20+
await use(queries)
21+
}
22+
23+
const isAllQuery = (query: Query): query is AllQuery => query.includes('All')
24+
25+
const registerSelectorsFixture: [
26+
TestFixture<void, PlaywrightTestArgs>,
27+
{scope: 'worker'; auto?: boolean},
28+
] = [
29+
async ({}, use) => {
30+
try {
31+
await selectors.register(
32+
'__testing_library__',
33+
(): SelectorEngine => ({
34+
query(root, selector) {
35+
const testingLibrary = window.TestingLibraryDom
36+
const {args, query} = JSON.parse(
37+
selector,
38+
window.__testingLibraryReviver,
39+
) as unknown as {
40+
query: keyof Queries
41+
args: Parameters<Queries[keyof Queries]>
42+
}
43+
44+
if (isAllQuery(query))
45+
throw new Error(
46+
`PlaywrightTestingLibrary: the plural '${query}' was used to create this Locator`,
47+
)
48+
49+
// @ts-expect-error
50+
const result = testingLibrary[query](root, ...args)
51+
52+
return result
53+
},
54+
queryAll(root, selector) {
55+
const testingLibrary = window.TestingLibraryDom
56+
const {args, query} = JSON.parse(
57+
selector,
58+
window.__testingLibraryReviver,
59+
) as unknown as {
60+
query: keyof Queries
61+
args: Parameters<Queries[keyof Queries]>
62+
}
63+
64+
// @ts-expect-error
65+
const result = testingLibrary[query](root, ...args)
66+
67+
if (!result)
68+
throw new Error(
69+
`PlaywrightTestingLibrary: '${query}' was used to create this Locator and the query returned \`null\``,
70+
)
71+
72+
return Array.isArray(result) ? result : [result]
73+
},
74+
}),
75+
)
76+
} catch (err) {
77+
// eslint-disable-next-line no-console
78+
console.error('Did not register testing library functions:', err)
79+
}
80+
await use()
81+
},
82+
{scope: 'worker', auto: true},
83+
]
84+
85+
const installTestingLibraryFixture: [
86+
TestFixture<void, PlaywrightTestArgs>,
87+
{scope: 'test'; auto?: boolean},
88+
] = [
89+
async ({context}, use) => {
90+
const testingLibraryDomUmdScript = await fs.readFile(
91+
// The location of the `@testing-library/dom` UMD script
92+
// is found inside the `node_modules/` directory.
93+
require.resolve('@testing-library/dom/dist/@testing-library/dom.umd.js'),
94+
'utf8',
95+
)
96+
97+
// On every page load, we must ensure that both `window['TestingLibraryDom']`
98+
// and `stripWrappingQuotesFromSelector` are available to our custom selectors.
99+
await context.addInitScript(`
100+
${testingLibraryDomUmdScript}
101+
102+
window.__testingLibraryReviver = ${reviver.toString()};
103+
`)
104+
105+
await use()
106+
},
107+
{scope: 'test', auto: true},
108+
]
109+
110+
export {queriesFixture, registerSelectorsFixture, installTestingLibraryFixture}
111+
export type {Queries}

lib/fixture/types.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import type * as TestingLibraryDom from '@testing-library/dom'
2+
import {Locator} from '@playwright/test'
3+
import {queries} from '@testing-library/dom'
4+
import {reviver} from './helpers'
5+
6+
/**
7+
* This type was copied across from Playwright
8+
*
9+
* @see {@link https://github.com/microsoft/playwright/blob/82ff85b106e31ffd7b3702aef260c9c460cfb10c/packages/playwright-core/src/client/types.ts#L108-L117}
10+
*/
11+
export type SelectorEngine = {
12+
/**
13+
* Returns the first element matching given selector in the root's subtree.
14+
*/
15+
query(root: HTMLElement, selector: string): HTMLElement | null
16+
/**
17+
* Returns all elements matching given selector in the root's subtree.
18+
*/
19+
queryAll(root: HTMLElement, selector: string): HTMLElement[]
20+
}
21+
22+
type Queries = typeof queries
23+
24+
type StripNever<T> = {[P in keyof T as T[P] extends never ? never : P]: T[P]}
25+
type ConvertQuery<Query extends Queries[keyof Queries]> = Query extends (
26+
el: HTMLElement,
27+
...rest: infer Rest
28+
) => HTMLElement | (HTMLElement[] | null) | (HTMLElement | null)
29+
? (...args: Rest) => Locator
30+
: never
31+
32+
export type LocatorQueries = StripNever<{[K in keyof Queries]: ConvertQuery<Queries[K]>}>
33+
34+
export type Query = keyof Queries
35+
export type AllQuery = Extract<Query, `${string}All${string}`>
36+
export type FindQuery = Extract<Query, `find${string}`>
37+
38+
declare global {
39+
interface Window {
40+
TestingLibraryDom: typeof TestingLibraryDom
41+
__testingLibraryReviver: typeof reviver
42+
}
43+
}

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,11 @@
2020
"prepublishOnly": "npm run build",
2121
"start:standalone": "hover-scripts test",
2222
"test": "run-s build:testing-library test:*",
23+
"test:legacy": "run-s build:testing-library test:standalone test:fixture:legacy",
2324
"test:fixture": "playwright test",
25+
"test:fixture:legacy": "playwright test test/fixture/element-handles.test.ts",
2426
"test:standalone": "hover-scripts test --no-watch",
25-
"test:types": "tsc --noEmit",
26-
"validate": "run-s test"
27+
"test:types": "tsc --noEmit"
2728
},
2829
"repository": {
2930
"type": "git",

test/fixture/locators.test.ts

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import * as playwright from '@playwright/test'
2+
import * as path from 'path'
3+
import {
4+
locatorFixtures as fixtures,
5+
LocatorFixtures as TestingLibraryFixtures,
6+
} from '../../lib/fixture'
7+
8+
const test = playwright.test.extend<TestingLibraryFixtures>(fixtures)
9+
10+
const {expect} = test
11+
12+
test.describe('lib/fixture.ts (locators)', () => {
13+
test.beforeEach(async ({page}) => {
14+
await page.goto(`file://${path.join(__dirname, '../fixtures/page.html')}`)
15+
})
16+
17+
test('should handle the query* methods', async ({queries: {queryByText}}) => {
18+
const element = queryByText('Hello h1')
19+
20+
expect(element).toBeTruthy()
21+
expect(await element.textContent()).toEqual('Hello h1')
22+
})
23+
24+
test('should use the new v3 methods', async ({queries: {queryByRole}}) => {
25+
const element = queryByRole('presentation')
26+
27+
expect(element).toBeTruthy()
28+
expect(await element.textContent()).toContain('Layout table')
29+
})
30+
31+
test('should handle regex matching', async ({queries: {queryByText}}) => {
32+
const element = queryByText(/HeLlO h(1|7)/i)
33+
34+
expect(element).toBeTruthy()
35+
expect(await element.textContent()).toEqual('Hello h1')
36+
})
37+
38+
test('should handle the get* methods', async ({queries: {getByTestId}}) => {
39+
const element = getByTestId('testid-text-input')
40+
41+
expect(await element.evaluate(el => el.outerHTML)).toMatch(
42+
`<input type="text" data-testid="testid-text-input">`,
43+
)
44+
})
45+
46+
test('handles page navigations', async ({queries: {getByText}, page}) => {
47+
await page.goto(`file://${path.join(__dirname, '../fixtures/page.html')}`)
48+
49+
const element = getByText('Hello h1')
50+
51+
expect(await element.textContent()).toEqual('Hello h1')
52+
})
53+
54+
test('should handle the get* method failures', async ({queries}) => {
55+
const {getByTitle} = queries
56+
// Use the scoped element so the pretty HTML snapshot is smaller
57+
58+
await expect(async () => getByTitle('missing').textContent()).rejects.toThrow()
59+
})
60+
61+
test('should handle the LabelText methods', async ({queries}) => {
62+
const {getByLabelText} = queries
63+
const element = getByLabelText('Label A')
64+
65+
/* istanbul ignore next */
66+
expect(await element.evaluate(el => el.outerHTML)).toMatch(
67+
`<input id="label-text-input" type="text">`,
68+
)
69+
})
70+
71+
test('should handle the queryAll* methods', async ({queries}) => {
72+
const {queryAllByText} = queries
73+
const elements = queryAllByText(/Hello/)
74+
75+
expect(await elements.count()).toEqual(3)
76+
77+
const text = await Promise.all([
78+
elements.nth(0).textContent(),
79+
elements.nth(1).textContent(),
80+
elements.nth(2).textContent(),
81+
])
82+
83+
expect(text).toEqual(['Hello h1', 'Hello h2', 'Hello h3'])
84+
})
85+
86+
test('should handle the queryAll* methods with a selector', async ({queries}) => {
87+
const {queryAllByText} = queries
88+
const elements = queryAllByText(/Hello/, {selector: 'h2'})
89+
90+
expect(await elements.count()).toEqual(1)
91+
92+
expect(await elements.textContent()).toEqual('Hello h2')
93+
})
94+
95+
test('should handle the getBy* methods with a selector', async ({queries}) => {
96+
const {getByText} = queries
97+
const element = getByText(/Hello/, {selector: 'h2'})
98+
99+
expect(await element.textContent()).toEqual('Hello h2')
100+
})
101+
102+
test('should handle the getBy* methods with a regex name', async ({queries}) => {
103+
const {getByRole} = queries
104+
const element = getByRole('button', {name: /getBy.*Test/})
105+
106+
expect(await element.textContent()).toEqual('getByRole Test')
107+
})
108+
109+
test('supports `hidden` option when querying by role', async ({queries: {queryAllByRole}}) => {
110+
const elements = queryAllByRole('img')
111+
const hiddenElements = queryAllByRole('img', {hidden: true})
112+
113+
expect(await elements.count()).toEqual(1)
114+
expect(await hiddenElements.count()).toEqual(2)
115+
})
116+
117+
test.describe('querying by role with `level` option', () => {
118+
test('retrieves the correct elements when querying all by role', async ({
119+
queries: {queryAllByRole},
120+
}) => {
121+
const elements = queryAllByRole('heading')
122+
const levelOneElements = queryAllByRole('heading', {level: 3})
123+
124+
expect(await elements.count()).toEqual(3)
125+
expect(await levelOneElements.count()).toEqual(1)
126+
})
127+
128+
test('does not throw when querying for a specific element', async ({queries: {getByRole}}) => {
129+
expect.assertions(1)
130+
131+
await expect(getByRole('heading', {level: 3}).textContent()).resolves.not.toThrow()
132+
})
133+
})
134+
135+
// TODO: scoped queries with `within`
136+
// TODO: configuration
137+
// TDOO: deferred page (do we need some alternative to `findBy*`?)
138+
})

0 commit comments

Comments
 (0)