Skip to content

Paths and the registry

In Playwright you keep a locator in a variable or a field and chain from it. POMWright names it instead: every locator has a path, a dot-delimited name that says where the element is, and lives in a registry that holds one locator per path. A page object has its own registry; the same registry is available without a page object.

A path is a string of segments separated by dots, from the outside of the page inwards:

type Paths =
| "main"
| "main.form@login"
| "main.form@login.input@email"
| "main.form@login.button@login";

Each path’s ancestors are its prefixes: main and main.form@login are ancestors of main.form@login.button@login. A segment is any text without a dot or whitespace. Names that say what and which (form@login, input@email) keep paths readable in reports; the @ is a convention, not syntax.

The union is the list of everything the registry can hold. TypeScript checks every path you pass against it, and offers the paths as completions:

registry.getNestedLocator("main.button@login");
// @ts-expect-error a path that is not in the union is a compile error
registry.getNestedLocator("main.buton@login");

A path is not empty, does not start or end with a dot, has no two dots in a row and contains no whitespace: none of the 25 Unicode whitespace characters (including the no-break space) and not U+FEFF. TypeScript rejects a path literal that breaks a rule, and POMWright checks every path again when it is registered or resolved, so a path built at runtime is checked too:

LocatorSchemaPath string cannot be empty
LocatorSchemaPath string cannot start with a dot: .main
LocatorSchemaPath string cannot end with a dot: main.
LocatorSchemaPath string cannot contain consecutive dots: main..form
LocatorSchemaPath string cannot contain whitespace chars: main.form button
LocatorSchemaPath must be a string; received 42.

A whitespace character that is hard to see is shown escaped: a no-break space reads main\u00A0.form.

Only the paths you resolve need a locator: a segment without one is a name that groups paths, and resolution leaves it out of the chain. See Resolving locators.

Every page object has its own registry and offers its methods: this.add(path) registers a locator, and getNestedLocator, getLocator and getLocatorSchema resolve one. The registry is created with the page object and checked right after defineLocators() has run.

Without a page object, createRegistryWithAccessors(page) creates a registry for a Playwright page and returns it with the same methods bound to it:

type Paths = "main" | "main.button@save";
const { registry, add, getNestedLocator } = createRegistryWithAccessors<Paths>(page);
add("main").locator("main");
add("main.button@save").getByRole("button", { name: "Save" });
registry.verify();
await getNestedLocator("main.button@save").click();

A registry you create yourself is checked when you call registry.verify(), once everything is registered; a page object calls it for you. See Verifying a registry.

What happens to a registration, from the call to the Locator an action uses:

flowchart TD
  add["add(path).getByRole(…).filter(…)"] -->|"copies the arguments"| record["record: path, definition, steps, description"]
  record --> registry[("registry: one record per path")]
  registry -->|"getNestedLocator(path)"| copies["a copy of each registered record on the path"]
  copies -->|"chains definitions and steps"| locator["a Playwright Locator, described by its path"]

The registry keeps copies of what you pass, and every resolution works on copies of the records, so nothing done to a resolved locator, an options object or a query changes what is registered.