Typed, decorator-driven selector composition for Playwright. Keep selectors close to your page objects without forcing a single Page Object Model style.
Playwright locators are powerful, but selector logic often leaks into tests:
- Selector strings get duplicated across files
- Long locator chains obscure the actual UI structure
- Reusable UI parts become scattered ad-hoc helpers
- Adopting a structured Page Object Model feels like an all-or-nothing rewrite
playwright-page-object provides an incremental path forward:
- Root decorators scope a class to a top-level locator
- Child decorators resolve selectors relative to that scope
- Lazy chains rebuild only when accessed
- Multiple output styles support your existing patterns
Use it with plain classes, custom controls, or built-in PageObject helpers—no breaking changes required.
npm install -D playwright-page-objectRequirements:
- Node
>=20 @playwright/test >=1.35.0- TypeScript
>=5.0(when using decorators + accessors)
Ensure your tsconfig.json targets "ES2015" or higher for ECMAScript accessor support.
No need to extend built-in classes—start with plain classes:
import type { Locator, Page } from "@playwright/test";
import { RootSelector, Selector, SelectorByRole } from "playwright-page-object";
class ButtonControl {
constructor(readonly locator: Locator) {}
}
@RootSelector("CheckoutPage")
class CheckoutPage {
constructor(readonly page: Page) {}
@Selector("PromoCodeInput")
accessor PromoCodeInput!: Locator;
@SelectorByRole("button", { name: "Apply" }, ButtonControl)
accessor ApplyPromoButton!: ButtonControl;
async applyPromoCode(code: string) {
await this.PromoCodeInput.fill(code);
await this.ApplyPromoButton.locator.click();
}
}import { test } from "@playwright/test";
test("apply promo code", async ({ page }) => {
const checkout = new CheckoutPage(page);
await checkout.applyPromoCode("SAVE20");
});Skip @RootSelector when your data-testid values are globally unique:
import type { Locator, Page } from "@playwright/test";
import { Selector, SelectorByRole } from "playwright-page-object";
class ButtonControl {
constructor(readonly locator: Locator) {}
}
class CheckoutPage {
constructor(readonly page: Page) {}
@Selector("PromoCodeInput")
accessor PromoCodeInput!: Locator;
@SelectorByRole("button", { name: "Apply" }, ButtonControl)
accessor ApplyPromoButton!: ButtonControl;
}See PlainHostCheckoutPage.ts and PromoSectionFragment.ts for a fuller example.
Pass a class to @Selector(...) to create reusable fragments. If that class exposes a locator property, nested decorators chain under that element:
import type { Locator, Page } from "@playwright/test";
import { Selector } from "playwright-page-object";
class PromoSection {
constructor(readonly locator: Locator) {}
@Selector("PromoCodeInput")
accessor PromoInput!: Locator;
}
class CheckoutPage {
constructor(readonly page: Page) {}
@Selector("PromoSection", PromoSection)
accessor promo!: PromoSection;
}Scoped root — establishes a container-level context:
@RootSelector("CheckoutPage")
class CheckoutPage {
constructor(readonly page: Page) {}
@Selector("PromoCodeInput")
accessor PromoCodeInput!: Locator;
}
// Resolves to: page.locator("body").getByTestId("CheckoutPage").getByTestId("PromoCodeInput")Page-only host — chains from body without a scoped container:
class CheckoutPage {
constructor(readonly page: Page) {}
@Selector("PromoCodeInput")
accessor PromoCodeInput!: Locator;
}
// Resolves to: page.locator("body").getByTestId("PromoCodeInput")Use a scoped root when relying on a container test id; use page-only when globally unique ids suffice.
Child decorators resolve in this order:
- Decorator-managed locator context (from
@RootSelector,PageObject, etc.) Locator-likelocatorproperty (fragments)- Playwright
pageproperty →page.locator("body") - Error if none match
If both locator and page exist, locator wins (element scope > page scope).
Locator— raw Playwright locator- Custom class — decorator passes resolved locator to that constructor
PageObject/ListPageObject— use built-in helpers
@RootSelector("CheckoutPage")
class CheckoutPage {
constructor(readonly page: Page) {}
@Selector("PromoCodeInput")
accessor PromoCodeInput!: Locator;
}
await checkoutPage.PromoCodeInput.fill("SAVE20");Drop @RootSelector if body scope suffices.
class ExternalInputControl {
constructor(readonly locator: Locator) {}
fill(value: string) {
return this.locator.fill(value);
}
}
@RootSelector("CheckoutPage")
class CheckoutPage {
constructor(readonly page: Page) {}
@Selector("PromoCodeInput", ExternalInputControl)
accessor PromoCode!: ExternalInputControl;
}
await checkoutPage.PromoCode.fill("SAVE20");import {
ListPageObject,
ListSelector,
PageObject,
RootPageObject,
RootSelector,
Selector,
SelectorByRole,
} from "playwright-page-object";
class CartItemControl extends PageObject {
@SelectorByRole("button", { name: "Remove" })
accessor RemoveButton = new PageObject();
}
@RootSelector("CheckoutPage")
class CheckoutPage extends RootPageObject {
@Selector("PromoCodeInput")
accessor PromoCode = new PageObject();
@SelectorByRole("button", { name: "Apply" })
accessor ApplyPromoButton = new PageObject();
@ListSelector("CartItem_")
accessor CartItems = new ListPageObject(CartItemControl);
}
await checkoutPage.PromoCode.$.fill("SAVE20");
await checkoutPage.expect().toBeVisible();
await checkoutPage.CartItems.waitCount(3);Styles coexist in the same class—mix as needed.
Use for root-decorated classes instantiated directly from page:
@RootSelector("CheckoutPage")
class CheckoutPage extends RootPageObject {}
const checkout = new CheckoutPage(page);Use for nested controls. Provides:
| Feature | Example |
|---|---|
| Raw locator | await control.$.click() |
| Assertions | await control.expect().toBeVisible() |
| Wait helpers | await control.waitVisible() |
| Page access | control.page() |
Wait methods:
| Method | Purpose |
|---|---|
.waitVisible() / .waitHidden() |
Wait for visibility |
.waitText(text) |
Wait for exact text |
.waitValue(value) / .waitNoValue() |
Wait for input value |
.waitCount(count) |
Wait for element count |
.waitChecked() / .waitUnChecked() |
Wait for checkbox state |
.waitProp(name, value) |
Wait for React/Vue data prop |
.waitPropAbsence(name) |
Wait for data prop absence |
Assertion methods:
await control.expect().toBeVisible();
await control.expect({ soft: true }).toHaveText("Click me");
await control.expect({ message: "Button is enabled" }).toBeEnabled();Use for repeated child controls:
@ListSelector("CartItem_")
accessor CartItems = new ListPageObject(CartItemControl);Common APIs:
list.items[0] // First item
list.items.at(-1) // Last item
for await (const item of list.items) {} // Iterate
await list.count() // Item count
list.first() // First item (PageObject)
list.last() // Last item (PageObject)
list.filterByText("Apple") // Narrowed ListPageObject
list.filterByTestId("CartItem_2") // Narrow by item test id
list.getItemByText("Apple") // First matching item
list.getItemByTestId("CartItem_2") // First item by item test id
list.getItemByRole("button", { name: "Remove" }) // First item containing that rolefilter... methods return a narrowed ListPageObject, so you can continue with .first(), .count(), .getAll(), or for await...of. getItemBy... methods return a single item. Use filterByHasTestId() when you want Playwright has-style matching for rows containing a child with a given test id instead of matching the row's own test id.
For Locator-based lists (multi-element without helpers):
@ListSelector("CartItem_")
accessor CartItemRows!: Locator;
// Use: cartPage.CartItemRows.nth(0), expect().toHaveCount()Prefer declarative row ids (CartItem_1, CartItem_2) to keep selectors readable.
createFixtures() works with classes constructible via new Class(page):
import { test as base } from "@playwright/test";
import { createFixtures } from "playwright-page-object";
import { CheckoutPage } from "./page-objects/CheckoutPage";
export const test = base.extend<{ checkoutPage: CheckoutPage }>(
createFixtures({
checkoutPage: CheckoutPage,
}),
);For classes with custom constructor arguments, create them in your own fixture.
| Decorator | Playwright API |
|---|---|
@RootSelector(id) |
getByTestId(id) |
@RootSelector() |
page.locator("body") |
@ListRootSelector(id) |
getByTestId(new RegExp(id)) |
@RootSelectorByRole(...) |
getByRole(...) |
@RootSelectorByText(text) |
getByText(text) |
@RootSelectorByLabel(...) |
getByLabel(...) |
@RootSelectorByPlaceholder(...) |
getByPlaceholder(...) |
@RootSelectorByAltText(...) |
getByAltText(...) |
@RootSelectorByTitle(...) |
getByTitle(...) |
| Decorator | Playwright API |
|---|---|
@Selector(id) |
getByTestId(id) |
@Selector() |
Identity (no chaining) |
@ListSelector(id) |
getByTestId(new RegExp(id)) |
@ListStrictSelector(id) |
getByTestId(id) (strict) |
@SelectorByRole(...) |
getByRole(...) |
@SelectorByText(text) |
getByText(text) |
@SelectorByLabel(...) |
getByLabel(...) |
@SelectorByPlaceholder(...) |
getByPlaceholder(...) |
@SelectorByAltText(...) |
getByAltText(...) |
@SelectorByTitle(...) |
getByTitle(...) |
@SelectorBy(fn) |
Custom locator logic |
Each can return raw Locator, custom controls, PageObject, or ListPageObject.
@RootSelector("CheckoutPage")
class CheckoutPage {
constructor(readonly page: Page) {}
@Selector("PromoCodeInput")
accessor PromoCodeInput!: Locator;
}@Selector("PromoCodeInput", ExternalInputControl)
accessor PromoCode!: ExternalInputControl;class CheckoutPage extends RootPageObject {
@Selector("PromoCodeInput")
accessor PromoCode = new PageObject();
}Mix all three approaches in the same suite.
This package is available in Context7 MCP, so AI assistants can load it directly into context when working with your object property paths.
A Cubic wiki provides AI-ready documentation for this project.
It also ships an Agent Skills – compatible skill. Install it so your AI assistant loads data-path guidance:
npx ctx7 skills install /sergeyshmakov/playwright-page-object playwright-page-objectThe skill lives in skills/playwright-page-object/SKILL.md.
See CONTRIBUTING.md for contribution guidelines.