import { test, selectors } from '@playwright/test'; | |
import { readFile } from 'fs-extra'; | |
import type * as TestingLibraryDom from '@testing-library/dom'; | |
// This type was copied across from Playwright's repo. | |
// See: https://github.com/microsoft/playwright/blob/82ff85b106e31ffd7b3702aef260c9c460cfb10c/packages/playwright-core/src/client/types.ts#L108-L117 | |
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[]; | |
}; | |
// Both `SelectorEngine#query` and `SelectorEngine#queryAll` receive a selector | |
// including any quote marks wrapped around it. The `@testing-library/dom` methods we're | |
// using internally expect to be passed strings that are not wrapped in quote marks | |
// therefore we strip them if they exist. | |
function stripWrappingQuotesFromSelector(selector: string): string { | |
const firstChar = selector.charAt(0); | |
const lastChar = selector.charAt(selector.length - 1); | |
if ( | |
(firstChar === `'` && lastChar === `'`) || | |
(firstChar === `"` && lastChar === `"`) || | |
(firstChar === '`' && lastChar === '`') | |
) { | |
return selector.substring(1, selector.length - 1); | |
} | |
return selector; | |
} | |
// **The more your tests resemble the way your software is used, the more confidence they can give you.** | |
// | |
// With this in mind, we recommend this order of priority: | |
// | |
// 1. `role=button >> text="Text inside button"` | |
// 2. `label="Label name of form element displayed to user"` | |
// 3. `placeholder="Placeholder text of input element displayed to user"` | |
// 4. `text="Text currently shown on page"` | |
// 5. `value="current-value"` | |
// 6. `alt="Alternative text used to describe image"` | |
// 7. `title="Advisory information against element"` | |
// 8. `data-testid="some-test-id-you-attached-to-the-element"` | |
// | |
// Selectors defined as `engine=body` or in short-form can be combined with the `>>` token, | |
// e.g. `selector1 >> selector2 >> selector3`. When selectors are chained, the next one is | |
// queried relative to the previous one's result. | |
// | |
// See: https://testing-library.com/docs/queries/about#priority | |
// See: https://playwright.dev/docs/selectors#best-practices | |
// See: https://playwright.dev/docs/selectors/#chaining-selectors | |
export function installTestingLibrarySelectors() { | |
// We use `selectors#register` to register custom selectors in the browser. | |
// | |
// It calls `toString` on any function passed into it, before transpiling | |
// this and sending it on to the browser. Therefore, any reference here | |
// to imports in this file are unreliable due to renaming (e.g. `_dom`, | |
// `_dom2`, etc). | |
// | |
// That is why we grab `@testing-library/dom` from the `window` and need | |
// to inject any objects or functions into the page before the selectors | |
// can work. | |
// | |
// See: https://playwright.dev/docs/api/class-selectors/#selectors-register | |
test.beforeAll(async () => { | |
await selectors.register( | |
'role', | |
(): SelectorEngine => ({ | |
query(root, selector) { | |
// @ts-ignore | |
const testingLibrary: typeof TestingLibraryDom = window['TestingLibraryDom']; | |
return testingLibrary.queryByRole(root, stripWrappingQuotesFromSelector(selector)); | |
}, | |
queryAll(root, selector) { | |
// @ts-ignore | |
const testingLibrary: typeof TestingLibraryDom = window['TestingLibraryDom']; | |
return testingLibrary.queryAllByRole(root, stripWrappingQuotesFromSelector(selector)); | |
}, | |
}) | |
); | |
await selectors.register( | |
'label', | |
(): SelectorEngine => ({ | |
query(root, selector) { | |
// @ts-ignore | |
const testingLibrary: typeof TestingLibraryDom = window['TestingLibraryDom']; | |
return testingLibrary.queryByLabelText(root, stripWrappingQuotesFromSelector(selector)); | |
}, | |
queryAll(root, selector) { | |
// @ts-ignore | |
const testingLibrary: typeof TestingLibraryDom = window['TestingLibraryDom']; | |
return testingLibrary.queryAllByLabelText(root, stripWrappingQuotesFromSelector(selector)); | |
}, | |
}) | |
); | |
await selectors.register( | |
'placeholder', | |
(): SelectorEngine => ({ | |
query(root, selector) { | |
// @ts-ignore | |
const testingLibrary: typeof TestingLibraryDom = window['TestingLibraryDom']; | |
return testingLibrary.queryByPlaceholderText(root, stripWrappingQuotesFromSelector(selector)); | |
}, | |
queryAll(root, selector) { | |
// @ts-ignore | |
const testingLibrary: typeof TestingLibraryDom = window['TestingLibraryDom']; | |
return testingLibrary.queryAllByPlaceholderText(root, stripWrappingQuotesFromSelector(selector)); | |
}, | |
}) | |
); | |
await selectors.register( | |
'value', | |
(): SelectorEngine => ({ | |
query(root, selector) { | |
// @ts-ignore | |
const testingLibrary: typeof TestingLibraryDom = window['TestingLibraryDom']; | |
return testingLibrary.queryByDisplayValue(root, stripWrappingQuotesFromSelector(selector)); | |
}, | |
queryAll(root, selector) { | |
// @ts-ignore | |
const testingLibrary: typeof TestingLibraryDom = window['TestingLibraryDom']; | |
return testingLibrary.queryAllByDisplayValue(root, stripWrappingQuotesFromSelector(selector)); | |
}, | |
}) | |
); | |
await selectors.register( | |
'alt', | |
(): SelectorEngine => ({ | |
query(root, selector) { | |
// @ts-ignore | |
const testingLibrary: typeof TestingLibraryDom = window['TestingLibraryDom']; | |
return testingLibrary.queryByAltText(root, stripWrappingQuotesFromSelector(selector)); | |
}, | |
queryAll(root, selector) { | |
// @ts-ignore | |
const testingLibrary: typeof TestingLibraryDom = window['TestingLibraryDom']; | |
return testingLibrary.queryAllByAltText(root, stripWrappingQuotesFromSelector(selector)); | |
}, | |
}) | |
); | |
await selectors.register( | |
'title', | |
(): SelectorEngine => ({ | |
query(root, selector) { | |
// @ts-ignore | |
const testingLibrary: typeof TestingLibraryDom = window['TestingLibraryDom']; | |
return testingLibrary.queryByTitle(root, stripWrappingQuotesFromSelector(selector)); | |
}, | |
queryAll(root, selector) { | |
// @ts-ignore | |
const testingLibrary: typeof TestingLibraryDom = window['TestingLibraryDom']; | |
return testingLibrary.queryAllByTitle(root, stripWrappingQuotesFromSelector(selector)); | |
}, | |
}) | |
); | |
}); | |
test.beforeEach(async ({ context }) => { | |
const testingLibraryDomUmdScript = await readFile( | |
// The location of the `@testing-library/dom` UMD script is found inside | |
// the `node_modules/` directory. | |
require.resolve('@testing-library/dom/dist/@testing-library/dom.umd.js'), | |
'utf8' | |
); | |
// On every page load, we must ensure that both `window['TestingLibraryDom']` | |
// and `stripWrappingQuotesFromSelector` are available to our custom selectors. | |
await context.addInitScript(` | |
${testingLibraryDomUmdScript} | |
${stripWrappingQuotesFromSelector} | |
`); | |
}); | |
} |
Thanks for this!
We've been trying out a slightly modified version of this now, but in CI we are running to Error: "role" selector engine has been already registered
error when running the tests (with a single worker). Wrapping the registration in try / catch works (with conditionally snooping if the failure is a registration error), but any idea if the selectors could be registered, but any idea if selectors could be registered somewhere else than in beforeAll
? I tried to global-setup
for example but that didn't work.
I also filed that as microsoft/playwright#11058
I got good tips from microsoft/playwright#11058, which recommended adding the selectors as automatic fixtures, which worked great. Here's my current implementation, see the few changes on registering the selectors, scripts and usage:
/**
* Copied from https://gist.github.com/sebinsua/205682c7af6966fdadc103b4e15d0d8b as
* playwright-testing-library doesn't support Locators properly yet. See
* https://github.com/testing-library/playwright-testing-library/pull/330 for additional info.
*
* Usage:
*
*
import { test, except, installTestingLibraryScripts } from "./test-utils";
installTestingLibraryScripts();
test("role test", async ({ page }) => {
await page.goto("https://playwright.dev/");
await expect(page.locator("role=heading").first()).toContainText(
"Playwright enables reliable end-to-end testing for modern web apps."
);
});
test("placeholder test", async ({ page }) => {
await page.goto("https://playwright.dev/");
await page
.locator("placeholder=Search")
.type("typity typity", { delay: 100 });
});
test("label test", async ({ page }) => {
await page.goto("https://43cvn.csb.app/");
await page.locator("label=Standard").first().click();
await page.locator("label=Standard").first().type("OK?");
});
*/
/* eslint-disable
import/no-extraneous-dependencies,
@typescript-eslint/no-unsafe-assignment,
@typescript-eslint/restrict-template-expressions,
@typescript-eslint/ban-ts-comment,
dot-notation
*/
import { test as base, selectors } from '@playwright/test';
import { readFile } from 'fs/promises';
import type * as TestingLibraryDom from '@testing-library/dom';
declare global {
interface Window {
TestingLibraryDom: typeof TestingLibraryDom
}
}
// This type was copied across from Playwright's repo.
// See: https://github.com/microsoft/playwright/blob/82ff85b106e31ffd7b3702aef260c9c460cfb10c/packages/playwright-core/src/client/types.ts#L108-L117
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[];
};
// Both `SelectorEngine#query` and `SelectorEngine#queryAll` receive a selector
// including any quote marks wrapped around it. The `@testing-library/dom` methods we're
// using internally expect to be passed strings that are not wrapped in quote marks
// therefore we strip them if they exist.
function stripWrappingQuotesFromSelector(selector: string): string {
const firstChar = selector.charAt(0);
const lastChar = selector.charAt(selector.length - 1);
if (
(firstChar === `'` && lastChar === `'`) ||
(firstChar === `"` && lastChar === `"`) ||
(firstChar === '`' && lastChar === '`')
) {
return selector.substring(1, selector.length - 1);
}
return selector;
}
// **The more your tests resemble the way your software is used, the more confidence they can give you.**
//
// With this in mind, we recommend this order of priority:
//
// 1. `role=button >> text="Text inside button"`
// 2. `label="Label name of form element displayed to user"`
// 3. `placeholder="Placeholder text of input element displayed to user"`
// 4. `text="Text currently shown on page"`
// 5. `value="current-value"`
// 6. `alt="Alternative text used to describe image"`
// 7. `title="Advisory information against element"`
// 8. `data-testid="some-test-id-you-attached-to-the-element"`
//
// Selectors defined as `engine=body` or in short-form can be combined with the `>>` token,
// e.g. `selector1 >> selector2 >> selector3`. When selectors are chained, the next one is
// queried relative to the previous one's result.
//
// See: https://testing-library.com/docs/queries/about#priority
// See: https://playwright.dev/docs/selectors#best-practices
// See: https://playwright.dev/docs/selectors/#chaining-selectors
export default async function installTestingLibrarySelectors() {
// We use `selectors#register` to register custom selectors in the browser.
//
// It calls `toString` on any function passed into it, before transpiling
// this and sending it on to the browser. Therefore, any reference here
// to imports in this file are unreliable due to renaming (e.g. `_dom`,
// `_dom2`, etc).
//
// That is why we grab `@testing-library/dom` from the `window` and need
// to inject any objects or functions into the page before the selectors
// can work.
//
// See: https://playwright.dev/docs/api/class-selectors/#selectors-register
try {
await selectors.register(
'role',
(): SelectorEngine => ({
query(root, selector) {
const testingLibrary = window['TestingLibraryDom'];
return testingLibrary.queryByRole(
root,
stripWrappingQuotesFromSelector(selector),
);
},
queryAll(root, selector) {
const testingLibrary = window['TestingLibraryDom'];
return testingLibrary.queryAllByRole(
root,
stripWrappingQuotesFromSelector(selector),
);
},
}),
);
await selectors.register(
'label',
(): SelectorEngine => ({
query(root, selector) {
const testingLibrary = window['TestingLibraryDom'];
return testingLibrary.queryByLabelText(
root,
stripWrappingQuotesFromSelector(selector),
);
},
queryAll(root, selector) {
const testingLibrary = window['TestingLibraryDom'];
return testingLibrary.queryAllByLabelText(
root,
stripWrappingQuotesFromSelector(selector),
);
},
}),
);
await selectors.register(
'placeholder',
(): SelectorEngine => ({
query(root, selector) {
const testingLibrary = window['TestingLibraryDom'];
return testingLibrary.queryByPlaceholderText(
root,
stripWrappingQuotesFromSelector(selector),
);
},
queryAll(root, selector) {
const testingLibrary = window['TestingLibraryDom'];
return testingLibrary.queryAllByPlaceholderText(
root,
stripWrappingQuotesFromSelector(selector),
);
},
}),
);
await selectors.register(
'value',
(): SelectorEngine => ({
query(root, selector) {
const testingLibrary = window['TestingLibraryDom'];
return testingLibrary.queryByDisplayValue(
root,
stripWrappingQuotesFromSelector(selector),
);
},
queryAll(root, selector) {
const testingLibrary = window['TestingLibraryDom'];
return testingLibrary.queryAllByDisplayValue(
root,
stripWrappingQuotesFromSelector(selector),
);
},
}),
);
await selectors.register(
'alt',
(): SelectorEngine => ({
query(root, selector) {
const testingLibrary = window['TestingLibraryDom'];
return testingLibrary.queryByAltText(
root,
stripWrappingQuotesFromSelector(selector),
);
},
queryAll(root, selector) {
const testingLibrary = window['TestingLibraryDom'];
return testingLibrary.queryAllByAltText(
root,
stripWrappingQuotesFromSelector(selector),
);
},
}),
);
await selectors.register(
'title',
(): SelectorEngine => ({
query(root, selector) {
// @ts-ignore
const testingLibrary: typeof TestingLibraryDom =
window['TestingLibraryDom'];
return testingLibrary.queryByTitle(
root,
stripWrappingQuotesFromSelector(selector),
);
},
queryAll(root, selector) {
// @ts-ignore
const testingLibrary: typeof TestingLibraryDom =
window['TestingLibraryDom'];
return testingLibrary.queryAllByTitle(
root,
stripWrappingQuotesFromSelector(selector),
);
},
}),
);
} catch (err) {
// eslint-disable-next-line no-console
console.error('Did not register testing library functions:', err);
}
}
export const test = base.extend<Record<string, unknown>, { registerSelectors: void }>({
// eslint-disable-next-line no-empty-pattern
registerSelectors: [async ({}, use) => {
await installTestingLibrarySelectors();
await use();
}, { scope: 'worker', auto: true }],
});
export { expect } from '@playwright/test';
export function installTestingLibraryScripts() {
test.beforeEach(async ({ context }) => {
const testingLibraryDomUmdScript = await readFile(
// The location of the `@testing-library/dom` UMD script is found inside
// the `node_modules/` directory.
require.resolve('@testing-library/dom/dist/@testing-library/dom.umd.js'),
'utf8',
);
// On every page load, we must ensure that both `window['TestingLibraryDom']`
// and `stripWrappingQuotesFromSelector` are available to our custom selectors.
await context.addInitScript(`
${testingLibraryDomUmdScript}
${stripWrappingQuotesFromSelector}
`);
});
}
@petetnt Just seen this now. Thank you for posting some improvements to my Gist!
Looks like the next version of @playwright-testing-library/test
will do something similar to this (and supersede this logic).
Use like so: