Skip to content

Instantly share code, notes, and snippets.

@sebinsua
Last active June 21, 2022 12:49
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save sebinsua/205682c7af6966fdadc103b4e15d0d8b to your computer and use it in GitHub Desktop.
Save sebinsua/205682c7af6966fdadc103b4e15d0d8b to your computer and use it in GitHub Desktop.
Playwright `@testing-library/dom` custom selectors. Contributed to discussion here: https://github.com/hoverinc/playwright-testing-library/pull/330#issuecomment-956348391
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}
`);
});
}
@petetnt
Copy link

petetnt commented Dec 22, 2021

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

@petetnt
Copy link

petetnt commented Dec 23, 2021

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}
          `);
    });
  }

@sebinsua
Copy link
Author

sebinsua commented Jan 5, 2022

@petetnt Just seen this now. Thank you for posting some improvements to my Gist!

@sebinsua
Copy link
Author

Looks like the next version of @playwright-testing-library/test will do something similar to this (and supersede this logic).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment