| name | playwright-page-object |
| version | 2.0.0 |
| description | Decorator-driven Playwright selector composition for plain classes, fragments, external controls, and optional PageObject/ListPageObject helpers. Use when the user needs typed selectors, incremental POM adoption, fixture setup, or clarification on context resolution (@RootSelector, @Selector, createFixtures).
|
playwright-page-object Agent Skill
Quick Reference
This library is a locator-composition layer, not a framework requiring inheritance. Root decorators establish scope; child decorators resolve from context. The accessor type determines output: raw Locator, custom control, PageObject, or ListPageObject.
When to Use This Skill
- User is adding typed, decorator-driven selectors to Playwright tests
- User needs guidance on plain class vs. POM vs. fragment vs. external control setup
- User is migrating from raw locator chains without forcing a full rewrite
- User wants fixture wiring via
createFixtures()
- User is unclear on context resolution or decorator scope
Context Resolution Priority
Child decorators resolve in this order:
- Decorator-managed context — classes with
@RootSelector(...), RootPageObject, or plain decorated roots
- Locator-like
locator property — fragments pass context to children
- Page property —
page.locator("body") fallback
- Error — if none match and accessor is accessed
Key facts:
locator property takes precedence over page property
@Selector() (no args) is the identity selector — no chaining
@RootSelector() (no args) uses body scope, same as page-only hosts
- Page-only hosts behave like
@RootSelector(), not container-scoped
Host Patterns
| Pattern | Root Decorator | First Constructor Arg | Scope |
|---|
| Scoped root | @RootSelector("TID") | page: Page | Container TID |
| Page-only host | None | page: Page | page.locator("body") |
| Fragment | None | locator: Locator | Chained from parent |
| Built-in root | @RootSelector(...) + extends RootPageObject | page: Page | Container, with helpers |
Accessor Output Types
@Selector("Promo")
accessor PromoInput!: Locator;
@Selector("Promo", MyInputControl)
accessor PromoInput!: MyInputControl;
@Selector("Promo")
accessor PromoInput = new PageObject();
@ListSelector("Item_")
accessor Items = new ListPageObject(ItemControl);
Critical: When the accessor type is a PageObject subclass, always use the initializer form (= new MyControl()). Do NOT pass the class as a factory argument — the library will throw if you do.
Hard Rules
- Use
accessor on all decorated members
- Do not put
@RootSelector on a PageObject subclass — use RootPageObject instead
- Never use
PageObject as a root; only use it for nested controls
- For nested
PageObject subclasses with custom constructors, implement cloneWithContext()
createFixtures() works for constructors (new X(page)) and factory functions ((page) => new X(page, config))
- Preserve the user's existing style. Do not force built-in POM adoption unless explicitly requested
Style Selection Cheat Sheet
Plain Class + Decorators
✅ Incremental adoption, existing Playwright patterns, no inheritance needed
@RootSelector("CheckoutPage")
class CheckoutPage {
constructor(readonly page: Page) {}
@Selector("Promo")
accessor PromoInput!: Locator;
}
Page-Only Host
✅ Lightweight, globally unique test ids, minimal boilerplate
class CheckoutPage {
constructor(readonly page: Page) {}
@Selector("Promo")
accessor PromoInput!: Locator;
}
Fragment (Reusable Sub-tree)
✅ Nested UI components, own child selectors, shared across pages
class PromoSection {
constructor(readonly locator: Locator) {}
@Selector("CodeInput")
accessor CodeInput!: Locator;
}
@Selector("PromoSection", PromoSection)
accessor promo!: PromoSection;
External Controls
✅ Existing control libraries, typed helpers, no PageObject inheritance
class InputControl {
constructor(readonly locator: Locator) {}
async fill(value: string) { await this.locator.fill(value); }
}
@Selector("Promo", InputControl)
accessor PromoInput!: InputControl;
Built-In POM
✅ Need $, wait helpers (.waitVisible(), .waitText()), soft assertions, many nested controls
class CheckoutPage extends RootPageObject {
@Selector("Promo")
accessor PromoInput = new PageObject();
async applyPromo(code: string) {
await this.PromoInput.waitVisible();
await this.PromoInput.$.fill(code);
}
}
Lists
ListPageObject (stateful helpers)
Use when you need iteration, filtering, or item lookup:
@ListSelector("CartItem_")
accessor Items = new ListPageObject(CartItemControl);
await list.waitCount(3);
const filtered = list.filterByText("Apple");
const item = list.getItemByTestId("CartItem_2");
await filtered.count();
for await (const row of list.items) { }
filter... methods return a narrowed ListPageObject for chaining (.first(), .count(), .getAll(), async iteration). getItemBy... methods return a single item.
Self vs. descendant test id matching:
filterByItemTestId(id) — the item row itself has that data-testid
filterByHasTestId(id) — the item row contains a descendant with that data-testid
Raw Locator (pure Playwright)
Use when you want .nth(), .count(), and basic element operations:
@ListSelector("CartItem_")
accessor ItemRows!: Locator;
const count = await itemRows.count();
const first = itemRows.nth(0);
Tip: Use prefixed row ids (CartItem_1, CartItem_2) with @ListSelector("CartItem_") to avoid collision with child ids like CartItemName.
Fixtures
With createFixtures()
Supports constructors and factory functions:
export const test = base.extend(
createFixtures({
checkoutPage: CheckoutPage,
authPage: (page) => new AuthPage(page, authConfig),
}),
);
test("...", async ({ checkoutPage }) => {
await checkoutPage.PromoInput.fill("CODE");
});
Manual instantiation
Still valid and sometimes simpler:
test("...", async ({ page }) => {
const checkout = new CheckoutPage(page);
await checkout.PromoInput.fill("CODE");
});
PageObject Wait Methods
| Method | Accepts |
|---|
waitVisible() / waitHidden() | — |
waitText(text) | string | RegExp |
waitValue(value) | string | RegExp | number |
waitCount(count) | number |
waitChecked() / waitUnChecked() | — |
Common Issues & Solutions
Issue: "How do I choose between root decorator and page-only host?"
Solution: Use @RootSelector("ContainerId") if the page has a container test id you want to scope. Otherwise, skip it (page-only). Prefer page-only when test ids are globally unique.
Issue: "My child selector doesn't resolve."
Solution: Check context resolution order:
- Is the parent a decorated root or has
@RootSelector?
- Does the parent have a
locator property (fragment)?
- Does the parent have a
page property?
- Are decorators using
accessor?
Issue: "Can I mix raw Locators and PageObjects in one class?"
Solution: Yes. Mix accessor types freely. Return raw Locator where you don't need helpers, and PageObject where you do.
Issue: "I have an external control library. Do I use it?"
Solution: Yes. Pass your control class as the second argument to @Selector(...). If it doesn't accept Locator in its constructor, wrap it first. Do NOT pass a PageObject subclass as a factory arg — use = new MyControl() instead.
Issue: "Do I have to use createFixtures()?"
Solution: No. It's optional. Manual new CheckoutPage(page) is valid. Use fixtures only if your test suite already relies on them.
Recommended Adoption Path
Unless the user explicitly asks for a full built-in POM:
- Start small: Add
@Selector(...) to plain classes, keep raw Locator accessors
- Extract controls: When selectors repeat, move to external classes
- Add POM helpers only where they add value: Use
PageObject / ListPageObject for wait/filter/iterate operations
- These three styles coexist — no all-or-nothing commitment required
Examples to Reference
When in doubt, direct the user to:
For complete decorator and API reference, read README.md.
Principle: Decoration, Not Prescription
This library decorates your selectors; it does not dictate your class hierarchy. Favor the user's existing patterns. Mix styles as needed. Inheritance (via RootPageObject or PageObject) is optional — a convenience, not a requirement.