Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 135 additions & 37 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,27 +1,29 @@
# playwright-page-object

Decorator-driven Playwright selector composition for plain classes, external controls, and optional `PageObject` base classes.
Typed, decorator-driven selector composition for Playwright. Keep selectors close to your page objects without forcing a single Page Object Model style.

[![npm version](https://badge.fury.io/js/playwright-page-object.svg)](https://badge.fury.io/js/playwright-page-object)
[![License: ISC](https://img.shields.io/badge/License-ISC-blue.svg)](https://opensource.org/licenses/ISC)

## Why This Exists

Raw Playwright locator chains are powerful, but they tend to spread selector logic across tests:
Playwright locators are powerful, but selector logic often drifts into tests and one-off helpers:

- Locator strings get duplicated.
- Relative structure gets flattened into long chains.
- UI parts that should be reusable stay as ad hoc helpers.
- Incrementally moving to stronger abstractions usually means rewriting everything at once.
- Selector strings get duplicated.
- Long locator chains hide the UI structure.
- Reusable UI parts turn into ad hoc helpers.
- Moving to a stronger POM often feels like an all-or-nothing rewrite.

`playwright-page-object` solves that with decorators and typed accessors:
`playwright-page-object` gives you a more incremental path:

- Root decorators bind a class instance to a top-level locator.
- Child decorators resolve locators relative to that root.
- Root decorators scope a class to a top-level locator.
- Child decorators resolve relative to that current scope.
- A top-level class can skip root decorators and rely on Playwright **`page`** when body-level scope is enough.
- A **fragment** created from `@Selector("…", Factory)` with `constructor(readonly locator: Locator)` can host its own nested `@Selector*` accessors under **`this.locator`**.
- Accessors can return a raw `Locator`, your own control class, or the built-in `PageObject` classes.
- Selector chains stay lazy and are rebuilt only when accessed.

The library is class-instance agnostic. You can use it with:
Use it with the class style you already have:

- plain classes that follow the normal Playwright constructor shape `(page: Page, ...rest)`
- your own controls that accept a `Locator`
Expand All @@ -35,7 +37,13 @@ npm install -D playwright-page-object

Or use `yarn add -D`, `pnpm add -D`, or `bun add -D`.

This library relies on the ECMAScript `accessor` keyword, available in TypeScript 5.0+. Make sure your `tsconfig.json` targets a compatible environment such as `"target": "ES2015"` or higher.
Requirements:

- Node `>=20`
- `@playwright/test >=1.35.0`
- TypeScript `>=5.0` when you use decorator + `accessor` syntax

This library relies on the ECMAScript `accessor` keyword. Make sure your `tsconfig.json` targets a compatible environment such as `"target": "ES2015"` or higher.

## Quick Start

Expand Down Expand Up @@ -75,19 +83,78 @@ test("apply promo code", async ({ page }) => {
});
```

This stays a plain class. The decorators only teach the accessors how to resolve locators.

### Same idea without `@RootSelector`

Skip the root decorator when body-level scope is enough and your `data-testid` values are globally unique. Child decorators still work as long as the instance exposes Playwright **`page`**:

```ts
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;
}
```

A fuller version with `PageObject` and lists lives in [example/e2e/page-objects/PlainHostCheckoutPage.ts](example/e2e/page-objects/PlainHostCheckoutPage.ts). That example also wires [PromoSectionFragment](example/e2e/page-objects/PromoSectionFragment.ts) (`readonly locator` + nested `@Selector`) via `@Selector("PromoSection", PromoSectionFragment)`.

### Nested `@Selector*` under `this.locator` (fragment)

When you pass a **class** as the last argument to `@Selector(...)`, the library constructs it with the resolved parent locator. If that class exposes a **Locator-like** **`locator`** property (typical pattern: `constructor(readonly locator: Locator)`), its nested child decorators continue **under that element** instead of falling back to `page`:

```ts
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;
}
```

## Core Mental Model

### 1. Root decorators establish locator context
### 1. Root decorators establish locator context (optional if you have `page`)

Use `@RootSelector(...)` and its variants on top-level classes. For plain classes, the first constructor argument must be `page: Page`. If you want to use the built-in root base class, extend `RootPageObject`.
Use `@RootSelector(...)` and its variants on top-level classes when you want a **scoped** container root (for example a section `data-testid`). For plain classes, the first constructor argument must be `page: Page`. If you want the built-in base class for top-level objects, extend `RootPageObject`.

Do not use `@RootSelector(...)` on classes that extend `PageObject` directly. `PageObject` is for nested controls, not root classes.
**Without** a root decorator, the same plain class shape still works as long as the instance exposes Playwright **`page`**. In that case child decorators resolve from **`page.locator("body")`** — equivalent to **`@RootSelector()`** (body root), **not** equivalent to **`@RootSelector("SomeTid")`** (narrowed root).

Use `PageObject` for nested controls only. Top-level root-decorated classes should extend `RootPageObject`, not `PageObject`.

### 2. Child decorators resolve relative selectors

Decorators such as `@Selector(...)` and `@SelectorByRole(...)` resolve from the root context created by the root decorator.
Decorators such as `@Selector(...)` and `@SelectorByRole(...)` resolve from the **current context**, in order:

1. If the instance already has a decorator-managed locator context (including `PageObject`, `RootPageObject`, and plain classes decorated with `@RootSelector(...)`), selectors chain from that locator.
2. Otherwise, if the instance has a **Locator-like** **`locator`** property (typical: `constructor(readonly locator: Locator)` on a fragment from a selector factory), selectors chain from **`this.locator`**.
3. Otherwise, if the instance has a Playwright **`page`** property, selectors chain from **`page.locator("body")`**.
4. Otherwise resolution throws (see [Decorator reference](#decorator-reference)).

That means this:
If both **`locator`** and **`page`** are present, **`locator`** wins (fragment / element scope over body).

Scoped root versus body-only host:

```ts
@RootSelector("CheckoutPage")
Expand All @@ -102,14 +169,20 @@ class CheckoutPage {
behaves like:

```ts
page.getByTestId("CheckoutPage").getByTestId("PromoCodeInput");
page.locator("body").getByTestId("CheckoutPage").getByTestId("PromoCodeInput");
```

A host with **only** `page` and **no** `@RootSelector` behaves like **`@RootSelector()`** + child: for the same accessor, the chain is:

```ts
page.locator("body").getByTestId("PromoCodeInput");
```

but stays typed, reusable, and class-friendly.
Use a **scoped** `@RootSelector("…")` when you rely on a container test id; use a **`page`-only** host when body-level chaining and globally unique ids are enough.

### 3. The accessor value chooses the result shape

The same selector decorators can produce different kinds of results:
The same selector decorators can produce different result shapes:

- `Locator`: declare the accessor as `Locator`
- external control: pass a constructor or factory as the last decorator argument
Expand Down Expand Up @@ -140,6 +213,8 @@ Usage:
await checkoutPage.PromoCodeInput.fill("SAVE20");
```

You can drop `@RootSelector("CheckoutPage")` here when body scope and globally unique test ids are enough: same accessors, but the class only declares `constructor(readonly page: Page) {}` and imports `Selector` (no root decorator).

### External controls

Use this when you already have your own controls or want reusable abstractions without inheriting from the built-in `PageObject` classes.
Expand Down Expand Up @@ -177,9 +252,10 @@ class ExternalCheckoutPage {
Use this when you want batteries-included helpers like `$`, waits, assertions, and list composition.

```ts
import type { Locator } from "@playwright/test";
import {
ListPageObject,
ListStrictSelector,
ListSelector,
PageObject,
RootPageObject,
RootSelector,
Expand All @@ -202,15 +278,20 @@ class CheckoutPage extends RootPageObject {
@SelectorByRole("button", { name: "Apply" })
accessor ApplyPromoButton = new ButtonControl();

@ListStrictSelector("CartItem")
@ListSelector("CartItem_")
accessor CartItems = new ListPageObject(CartItemControl);

@ListSelector("CartItem_")
accessor CartItemRows!: Locator;
}
```

Give each repeated row a stable prefixed test id in markup (for example `CartItem_${id}`) and use `@ListSelector("CartItem_")` so the regex targets row roots only—not sibling ids such as `CartItemName` or `CartItemPrice`.

These styles can coexist in the same class. The example app includes a root class that mixes all of them:

- `PageObject` accessors for built-in helpers
- raw `Locator` accessors for minimal abstraction
- raw `Locator` accessors for minimal abstraction (including multi-element lists from `@ListSelector` / `@ListStrictSelector`)
- typed controls for reusable UI elements
- `ListPageObject` for collections

Expand All @@ -227,7 +308,7 @@ Use `RootPageObject` for root-decorated classes that are created directly from P
class CheckoutPage extends RootPageObject {}
```

Root page objects remain page-first: construct them with `new CheckoutPage(page)`.
Root page objects remain page-first: construct them with `new CheckoutPage(page)`. They are top-level objects only and cannot be nested under another selector.

### `PageObject`

Expand Down Expand Up @@ -259,20 +340,22 @@ Wait helpers:
| `.waitCount(count)` | Wait for the resolved count. |
| `.waitChecked()` / `.waitUnChecked()` | Wait for checkbox or radio state. |
| `.waitProp(name, value)` | Wait for a React or Vue data prop. |
| `.waitPropAbsence(name, value)` | Wait for a React or Vue data prop to stop matching a value. |

Assertions:

| Method | Description |
| --- | --- |
| `.expect()` | Returns a Playwright `expect(locator)` assertion API. |
| `.expect({ soft: true })` | Returns the soft-assertion variant. |
| `.expect({ message: "..." })` | Adds a custom assertion message. |

### `ListPageObject`

Use `ListPageObject` for repeated child controls and collections.

```ts
@ListStrictSelector("CartItem")
@ListSelector("CartItem_")
accessor CartItems = new ListPageObject(CartItemControl);
```

Expand All @@ -282,16 +365,35 @@ Useful APIs:
- `list.items.at(-1)`
- `for await (const item of list.items) { ... }`
- `await list.count()`
- `await list.getAll()`
- `list.first()`
- `list.second()`
- `list.last()`
- `list.filterByText("Apple")`
- `list.filterByTestId("CartItem_2")`
- `list.filterByText("Apple").first()`
- `list.getItemByText("Apple")`
- `list.getItemByRole("button", { name: "Remove" })`
- `list.getItemByIdMask("CartItem_")`

Helpers such as `first()`, `second()`, `last()`, `at()`, `getItemByText()`, `getItemByRole()`, and `getItemByIdMask()` return a single item page object. Helpers such as `filter()`, `filterByText()`, and `filterByTestId()` return a narrower `ListPageObject`, so chain `.first()` or `.at(...)` when you need one matched item.

### List rows without `ListPageObject`

You can type the accessor as `Locator` with the same list decorator. That yields Playwright’s multi-element locator (use `.nth()`, `.count()`, `expect(locator).toHaveCount()`, etc.):

```ts
@ListSelector("CartItem_")
accessor CartItemRows!: Locator;
```

Indexing and search helpers such as `first()`, `second()`, `at()`, `getItemByText()`, and `getItemByRole()` return a single item page object. Filter helpers such as `filter()`, `filterByText()`, and `filterByTestId()` return a narrower `ListPageObject`, so chain `.first()` or `.at(...)` when you need one matched item.
Prefer row test ids with a **declarative prefix** (`CartItem_1`, `CartItem_2`, …) so `@ListSelector("CartItem_")` stays readable and avoids accidental matches on related ids.

## Fixtures

`createFixtures()` works with any root class whose first constructor argument is `page: Page`. That includes both built-in `RootPageObject` classes and plain decorated classes.
`createFixtures()` works with classes that can be constructed with only `page: Page`. That includes built-in `RootPageObject` classes, plain classes with `@RootSelector`, and plain classes that rely only on a **`page`** property for child decorators (no root class decorator).

If your page object needs additional constructor arguments, create it in your own fixture instead of passing it directly to `createFixtures()`.

```ts
import { test as base } from "@playwright/test";
Expand Down Expand Up @@ -328,6 +430,8 @@ class CheckoutPage {
}
```

When you do not need a container test id, you can omit `@RootSelector("…")` and keep `constructor(readonly page: Page)` only. Tradeoff: selectors are not automatically narrowed to one section; prefer unique `data-testid` values (or add `@RootSelector("…")` later).

### 2. Introduce external controls where repetition appears

When the same locator patterns or UI behavior appear in multiple places, move them into your own control classes that accept a `Locator`.
Expand Down Expand Up @@ -383,25 +487,19 @@ You can mix all three approaches in the same codebase, fixture setup, and even t
| `@SelectorByTitle(...)` | `getByTitle(...)` |
| `@SelectorBy(fn)` | custom locator logic |

**Context resolution for child decorators:** first use the locator context created by decorators / built-in page objects; otherwise a Locator-like **`locator`** property; otherwise Playwright **`page`** → **`page.locator("body")`**; otherwise an error is thrown when the accessor is read.

Child decorators can return:

- raw `Locator`
- raw `Locator` (including list locators from `@ListSelector` / `@ListStrictSelector`)
- external controls via constructor or factory
- built-in `PageObject` or `ListPageObject`

## AI Ready

This package is available in [Context7](https://context7.com/) MCP, so AI assistants can load it directly into context when working with your Playwright tests.

A [Cubic wiki](https://www.cubic.dev/wikis/sergeyshmakov/playwright-page-object) provides AI-ready documentation for this project.

It also ships an [Agent Skills](https://agentskills.io/) compatible skill:

```bash
npx ctx7 skills install /sergeyshmakov/playwright-page-object playwright-page-object
```
This repo ships an [Agent Skills](https://agentskills.io/) compatible skill for assistants that support project-specific guidance.

The skill lives in [skills/playwright-page-object/SKILL.md](skills/playwright-page-object/SKILL.md).
The skill lives in [skills/playwright-page-object/SKILL.md](skills/playwright-page-object/SKILL.md). It documents the optional **`page`-only** host pattern, **fragment** controls with **`this.locator`**, scoped roots, and the built-in POM types.

## Contributing

Expand Down
9 changes: 6 additions & 3 deletions example/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,14 @@ Page objects compose into a typed graph:
CheckoutPage (@RootSelector)
├── PromoCodeInput (@Selector)
├── ApplyPromoButton (@SelectorByRole)
└── CartItems (@ListStrictSelector)
└── CartItemControl
└── RemoveButton (@SelectorByRole)
├── CartItems (@ListSelector CartItem_*)
│ └── CartItemControl
│ └── RemoveButton (@SelectorByRole)
└── CartItemRows (@ListSelector CartItem_*) — raw Locator, no ListPageObject
```

Cart line rows use `data-testid` values `CartItem_${id}` in `CartItem.tsx`, so `@ListSelector("CartItem_")` matches every row without colliding with `CartItemName` / `CartItemPrice`.

### Custom Methods (Repeatable Logic)

Controls encapsulate common checks and actions:
Expand Down
8 changes: 8 additions & 0 deletions example/e2e/checkout.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@ test("should apply promo using Locator-typed accessor (PromoCodeInput)", async (
await checkoutPage.expectCartHasItemCount(3);
});

test("should remove first cart row using @ListSelector Locator (no ListPageObject)", async ({
checkoutPage,
}) => {
await checkoutPage.applyPromoCode("SAVE20");
await checkoutPage.CartItemRows.nth(0).getByTestId("Remove").click();
await checkoutPage.expectCartHasItemCount(2);
});

test("should empty cart by removing all items", async ({ checkoutPage }) => {
const count = await checkoutPage.CartItems.count();
for (let i = 0; i < count; i++) {
Expand Down
3 changes: 3 additions & 0 deletions example/e2e/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@ import { test as base } from "@playwright/test";
import { createFixtures } from "playwright-page-object";
import { CheckoutPage } from "./page-objects/CheckoutPage";
import { ExternalCheckoutPage } from "./page-objects/ExternalCheckoutPage";
import { PlainHostCheckoutPage } from "./page-objects/PlainHostCheckoutPage";

export const test = base.extend<{
checkoutPage: CheckoutPage;
externalCheckoutPage: ExternalCheckoutPage;
plainHostCheckoutPage: PlainHostCheckoutPage;
}>(
createFixtures({
checkoutPage: CheckoutPage,
externalCheckoutPage: ExternalCheckoutPage,
plainHostCheckoutPage: PlainHostCheckoutPage,
}),
);
10 changes: 7 additions & 3 deletions example/e2e/page-objects/CheckoutPage.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Locator } from "@playwright/test";
import {
ListPageObject,
ListStrictSelector,
ListSelector,
PageObject,
RootPageObject,
RootSelector,
Expand All @@ -24,13 +24,17 @@ export class CheckoutPage extends RootPageObject {
@SelectorByRole("button", { name: "Apply" })
accessor ApplyPromoButton = new ButtonControl();

@ListStrictSelector("CartItem")
@ListSelector("CartItem_")
accessor CartItems = new ListPageObject(CartItemControl);

/** List without custom item type — items are plain PageObject instances */
@ListStrictSelector("CartItem")
@ListSelector("CartItem_")
accessor CartItemsAsPlainList = new ListPageObject();

/** Raw Playwright locator for all cart rows (no ListPageObject) */
@ListSelector("CartItem_")
accessor CartItemRows!: Locator;

async applyPromoCode(code: string) {
await this.PromoCode.$.fill(code);
await this.ApplyPromoButton.$.click();
Expand Down
Loading
Loading