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.
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.
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.