playwright-page-object
v1.4.2
Published
TypeScript-first Page Object Model framework for Playwright that replaces raw `page.locator()` chains with a typed, composable, decorator-driven control graph.
Readme
playwright-page-object
Typed, decorator-driven selector composition for Playwright. Keep selectors close to your page objects without forcing a single Page Object Model style.
Problem
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
Solution
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.
Installation
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.
Quick Start
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");
});Using page-only hosts (no root decorator)
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.
Composing fragments with @Selector + this.locator
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;
}How It Works
Root decorators set locator scope
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 from context
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).
Accessor type determines output shape
Locator— raw Playwright locator- Custom class — decorator passes resolved locator to that constructor
PageObject/ListPageObject— use built-in helpers
Output Styles
1. Raw Locator (minimal abstraction)
@RootSelector("CheckoutPage")
class CheckoutPage {
constructor(readonly page: Page) {}
@Selector("PromoCodeInput")
accessor PromoCodeInput!: Locator;
}
await checkoutPage.PromoCodeInput.fill("SAVE20");Drop @RootSelector if body scope suffices.
2. Custom Controls (reusable abstractions)
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");3. Built-in POM Layer (batteries included)
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.
Built-In Classes
RootPageObject
Use for root-decorated classes instantiated directly from page:
@RootSelector("CheckoutPage")
class CheckoutPage extends RootPageObject {}
const checkout = new CheckoutPage(page);PageObject
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();ListPageObject
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.
Fixtures
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 Reference
Root Decorators
| 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(...) |
Child Decorators
| 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.
Incremental Adoption
Step 1: Add decorators, keep Locator output
@RootSelector("CheckoutPage")
class CheckoutPage {
constructor(readonly page: Page) {}
@Selector("PromoCodeInput")
accessor PromoCodeInput!: Locator;
}Step 2: Extract reusable controls
@Selector("PromoCodeInput", ExternalInputControl)
accessor PromoCode!: ExternalInputControl;Step 3: Adopt PageObject where helpful
class CheckoutPage extends RootPageObject {
@Selector("PromoCodeInput")
accessor PromoCode = new PageObject();
}Mix all three approaches in the same suite.
AI & Assistant Support
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.
Contributing
See CONTRIBUTING.md for contribution guidelines.
