skip to content

When is registering a custom Playwright selector engine with selectors.register worth its maintenance cost?

level: principalimportance: nice to knowfreq 20%

answer

  1. Two functions form the engine contract
  2. Timing relative to page creation matters
  3. An isolation option exists and is preferable
  4. Weigh a private query language
  5. Ask who guarantees the scheme

basics

~20 s

Rarely. A custom engine earns its place only when a whole class of elements shares an addressing scheme the built-in engines cannot express and the application guarantees. Otherwise it adds a private query language every test author must learn.

solid answer

~50 s

`selectors.register(name, script, options)` adds a prefix to the selector language, backed by an engine exposing `query(root, selector)` and `queryAll(root, selector)` evaluated in the page. It pays when a *whole class* of elements shares an addressing scheme the built-in engines cannot express -- a structured identifier the design system stamps out, or a registry lookup for a virtualised list -- and the scheme is stable because the application owns it. It does not pay as a shortcut around awkward markup: a registered engine is a private query language, invisible to a reader, unusable in devtools, untyped, and failing as an empty match rather than an error. The constraints also bite: registration must happen before any page is created, a name registers only once, and `{ contentScript: true }` is worth taking for isolation from page scripts.

code

typescript · 16 lines
typescript
import { test as base } from '@playwright/test';

const createIssueEngine = () => ({
  query: (root, selector) => root.querySelector(`[data-issue-id="${selector}"]`),
  queryAll: (root, selector) =>
    Array.from(root.querySelectorAll(`[data-issue-id="${selector}"]`)),
});

export const test = base.extend<{}, { engines: void }>({
  engines: [async ({ playwright }, use) => {
    await playwright.selectors.register('issue', createIssueEngine, { contentScript: true });
    await use();
  }, { scope: 'worker', auto: true }],
});

// Tests can then write: page.locator('issue=ISS-412')

go deeper

for a junior

Know that Playwright lets you add your own selector prefix and that it is an advanced escape hatch. Reach for role, text, test-id, CSS and XPath first; you are unlikely to need to register anything.

for a middle

Be able to describe the engine contract, the query and queryAll functions, and the rule that registration must precede page creation. Explain what contentScript isolates the engine from and why that matters.

for a senior

Judge a proposal on operational cost: who registers it, what happens to a debugging session that does not, how failures surface, and whether an agreed test-id attribute would solve the same problem with no code.

for a principal

Own the decision that this becomes a private query language your organisation maintains. Set the bar at a scheme the application itself guarantees, and schedule a review that removes the engine once the built-in locators can express the same thing.

## What registering an engine actually buys you `selectors.register(name, script, options)` adds a new prefix to the selector language. After registering under the name `issue`, every API that takes a selector accepts `issue=ISS-412`, and the resulting locator composes with everything else -- chaining, filtering and assertions all work normally. The engine itself is a small object evaluated **in the page context**, exposing two functions: - `query(root, selector)` -- return the first element matching `selector` within `root`'s subtree. - `queryAll(root, selector)` -- return all of them. `root` is whatever the surrounding locator scoped to, so a registered engine participates in scoping for free. The `script` argument can be a function, a string of source, or `{ path }` / `{ content }` pointing at a file. ## The constraints that shape the decision 1. **Registration must happen before the page is created.** In the test runner that means a worker-scoped, auto-used fixture built on the `playwright` fixture, so every page in the worker sees the engine. Getting this wrong produces an unknown-engine error in an unrelated test. 2. **A name can only be registered once.** A second `register` call with the same name throws saying the engine has already been registered, which makes accidental double-registration across shared base tests a real failure mode. 3. **The name is restricted** to `[a-zA-Z0-9_]`, so `issue_id` is legal and `issue-id` is not. 4. **`{ contentScript: true }` isolates it.** The engine then runs in an isolated JavaScript environment that still sees the DOM but not the frame's own script objects. That protects it from an application that has tampered with globals such as `Node.prototype` methods. All built-in engines run this way. Isolation is not guaranteed when several custom engines are combined. | Option | Engine can call app code | Protected from global tampering | |---|---|---| | default (`contentScript` false) | yes | no | | `{ contentScript: true }` | no | yes | ## When it pays A custom engine earns its keep when a *whole class* of elements shares an addressing scheme the built-in engines cannot express, and the scheme is stable because the application owns it: - A design system that stamps a structured identifier -- component name plus instance id -- into one attribute, where the engine can parse the two halves and let tests write `issue=card/ISS-412`. - A canvas or virtualised list where the visible element for a domain object is looked up through an application registry rather than by DOM structure. - A framework-specific addressing scheme your team must support across hundreds of tests, where writing the parsing once beats repeating it in every selector. Note the shape of all three: the engine encodes a rule the *application* guarantees, not a shortcut around markup the test author finds awkward. ## When it does not Most proposals fail on cost rather than feasibility. A registered engine is a private query language: - It is invisible to anyone reading the test who has not read the engine's source. - It cannot be pasted into browser devtools to check what it matches. - It has no type checking, and its failures surface as empty matches rather than errors. - It must be registered correctly in every entry point, including ad-hoc scripts and debugging sessions, or selectors that work in the suite fail outside it. Against that, `css=`, `xpath=` and the attribute engines already cover almost everything, and an agreed test-id attribute covers the rest with no code at all. ## Owning it over time If you do register one, treat it as production code: keep it in the shared base test rather than duplicated per spec, prefer `{ contentScript: true }` so an application change to global prototypes cannot silently corrupt matching, and document the grammar next to the engine. The question to revisit each quarter is whether the engine still encodes a rule the application guarantees, or whether it has quietly become a place where selector hacks accumulate out of sight.

  • Why must selectors.register run before the page is created?
    The engine is injected into the page when it is set up, so a page created earlier never receives it and selectors using the prefix fail with an unknown-engine error. In the test runner the reliable place is a worker-scoped, auto-used fixture, which registers once per worker ahead of any page fixture.
  • What does the contentScript option change, and why prefer it?
    It runs the engine in an isolated JavaScript environment that still sees the DOM but not the frame's own script objects, so an application that has replaced `Node.prototype` methods cannot corrupt matching. All built-in engines run this way. The isolation is not guaranteed once several custom engines are combined.
  • What is the cheaper alternative you should rule out first?
    An agreed test-id attribute owned by the component, addressed with the built-in attribute engines or the test-id lookup. It requires no injected code, is readable in devtools, survives a Playwright upgrade untouched, and puts the contract in the application's markup where reviewers of the component can see it.

saying these in an interview costs you the question

  • Proposing a custom engine to work around awkward markup
  • Registering inside a test rather than before pages exist
  • Registering the same engine name twice across base tests
  • Skipping contentScript and letting page code affect matching
  • Assuming a registered engine can pierce a closed shadow root
  • Treating the engine as throwaway rather than production code