IKivan-kozenko -aqa
Try yourself as a QA tester
All topics·Advanced·18 / 24

Extensibility

Playwright lets you register custom selector engines via playwright.selectors.register(). The engine defines query() and queryAll() functions that run in the browser to find elements. I've only needed this once — for an internal component library that used custom data attributes that didn't fit standard locator strategies. In most cases, getByTestId with a custom testIdAttribute in config is enough.

When you'd actually need a custom selector engine

Almost never. The built-in locators cover almost everything: getByRole, getByLabel, getByText, getByTestId. If I need a custom test id attribute (say data-qa instead of data-testid), I just configure testIdAttribute in playwright.config.ts — that's usually enough.

The one real use case: an internal component library that exposes elements through a non-standard attribute or naming scheme that the built-in locators can't address. For example, a design system where all components have a data-component attribute with the component name — I can write an engine that finds elements by component name.

ts
// playwright.config.ts — зазвичай достатньо просто змінити атрибут
export default defineConfig({
  use: {
    // тепер page.getByTestId('save-btn') шукає data-qa="save-btn"
    testIdAttribute: 'data-qa',
  },
})

Registering a custom selector engine

A selector engine needs two functions: query() (returns first match) and queryAll() (returns all matches). Both run in the browser context. The engine must be registered before creating the page — in a worker-scoped fixture.

I register engines in a worker-scoped auto-fixture so the registration happens once per worker, not per test. This is important — registering the same engine name twice throws an error.

ts
// fixtures.ts — реєстрація кастомного рушія
import { test as base } from '@playwright/test'

// Рушій що знаходить елементи за атрибутом data-component
const createComponentEngine = () => ({
  query(root: Element, selector: string) {
    return root.querySelector(`[data-component="${selector}"]`)
  },
  queryAll(root: Element, selector: string) {
    return Array.from(root.querySelectorAll(`[data-component="${selector}"]`))
  },
})

export const test = base.extend({
  // Реєструвати один раз на воркер (auto: true — запускається автоматично)
  selectorRegistration: [async ({ playwright }, use) => {
    await playwright.selectors.register('component', createComponentEngine)
    await use()
  }, { scope: 'worker', auto: true }],
})
ts
// В тестах — тепер можна використовувати 'component=' префікс
import { test, expect } from './fixtures'

test('saves the order', async ({ page }) => {
  // Знайти елемент з data-component="OrderForm"
  const form = page.locator('component=OrderForm')

  // Поєднувати з вбудованими локаторами
  await form.getByLabel('Customer').fill('Acme Corp')
  await form.locator('component=SaveButton').click()

  await expect(page.locator('component=SuccessToast')).toBeVisible()
})

Content script mode for safety

By default, the engine runs in the same JavaScript context as the app — meaning the app could accidentally interfere with the engine (e.g., by overriding Node.prototype methods). Registering with { contentScript: true } runs the engine in an isolated content script context, protected from the app's JavaScript.

All built-in Playwright selector engines run as content scripts. I should do the same for any engine I write.

ts
// Реєстрація з ізоляцією content script
await playwright.selectors.register('component', createComponentEngine, {
  contentScript: true,
})