Skip to content

sergeyshmakov/playwright-page-object

Repository files navigation

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.

npm version License: ISC

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

Requirements:

  • 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:

  1. Decorator-managed locator context (from @RootSelector, PageObject, etc.)
  2. Locator-like locator property (fragments)
  3. Playwright page property → page.locator("body")
  4. 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 role

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

The skill lives in skills/playwright-page-object/SKILL.md.

Contributing

See CONTRIBUTING.md for contribution guidelines.

License

ISC License

About

TypeScript Page Object Model for Playwright — decorator-driven, typed, composable controls instead of raw locator strings.

Topics

Resources

License

Contributing

Stars

Watchers

Forks

Packages

 
 
 

Contributors