npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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.

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