Skip to content

Commit fa81827

Browse files
Merge pull request #6 from sergeyshmakov/2026-03-18-fix-list-page-object-api
fix: fix ambiguous list page object api
2 parents 9dd7b82 + c48adcf commit fa81827

File tree

15 files changed

+265
-399
lines changed

15 files changed

+265
-399
lines changed

README.md

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -227,11 +227,14 @@ Use `RootPageObject` for root-decorated classes that are created directly from P
227227
class CheckoutPage extends RootPageObject {}
228228
```
229229

230+
Root page objects remain page-first: construct them with `new CheckoutPage(page)`.
231+
230232
### `PageObject`
231233

232234
Use `PageObject` for nested controls. It provides:
233235

234236
- raw locator access via `$`
237+
- `page` derived from the current root context via `root.page()`
235238
- Playwright assertions via `.expect()`
236239
- wait helpers such as `.waitVisible()` and `.waitCount()`
237240
- nested control composition through selector decorators
@@ -244,6 +247,8 @@ await control.expect().toBeVisible();
244247
await cartItems.waitCount(0);
245248
```
246249

250+
Nested `PageObject` subclasses use the default constructor shape `(root?: Locator, selector?: SelectorType)`. If a nested subclass needs custom constructor arguments, implement `cloneWithContext()` so it can rebuild itself with the new `root` and `selector`.
251+
247252
Wait helpers:
248253

249254
| Method | Description |
@@ -277,8 +282,12 @@ Useful APIs:
277282
- `list.items.at(-1)`
278283
- `for await (const item of list.items) { ... }`
279284
- `await list.count()`
280-
- `await list.first()`
281-
- `await list.filterByText("Apple")`
285+
- `list.first()`
286+
- `list.second()`
287+
- `list.filterByText("Apple")`
288+
- `list.filterByText("Apple").first()`
289+
290+
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.
282291

283292
## Fixtures
284293

example/README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,8 +100,13 @@ for await (const item of checkoutPage.CartItems.items) {
100100
```typescript
101101
const widgetItems = checkoutPage.CartItems.filterByText("Widget");
102102
await widgetItems.expect().toHaveCount(2);
103+
104+
const widgetB = checkoutPage.CartItems.filterByText("Widget B").first();
105+
await widgetB.RemoveButton.$.click();
103106
```
104107

108+
`filterByText()` returns a narrowed `ListPageObject`. Call `.first()`, `.second()`, or `.at(...)` when you need a single matched item.
109+
105110
## Full API
106111

107112
See the [main package README](../README.md) for the complete API reference.

example/e2e/checkout.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,10 @@ test("should iterate over cart items with for await", async ({
4242
}
4343
});
4444

45-
test("should find item by text using filterByText and remove it", async ({
45+
test("should narrow the list by text and remove the first matched item", async ({
4646
checkoutPage,
4747
}) => {
48-
const widgetB = checkoutPage.CartItems.filterByText("Widget B");
48+
const widgetB = checkoutPage.CartItems.filterByText("Widget B").first();
4949
await widgetB.waitVisible();
5050
await widgetB.RemoveButton.$.click();
5151
await checkoutPage.expectCartHasItemCount(2);

example/package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

skills/playwright-page-object/SKILL.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,8 @@ When entering a codebase, detect these first:
4747
- `createFixtures(...)`
4848
- manual `new MyPage(page)`
4949
4. Custom nested `PageObject` constructors:
50-
- if a nested `PageObject` subclass does not use the default constructor shape, it must implement `cloneWithContext()`
50+
- the default nested `PageObject` constructor shape is `(root?: Locator, selector?: SelectorType)`
51+
- if a nested `PageObject` subclass does not use that shape, it must implement `cloneWithContext()`
5152

5253
Preserve the user's current style. Do not force a migration to the built-in POM if the codebase already uses plain classes or external controls successfully.
5354

@@ -107,7 +108,7 @@ class CheckoutPage extends RootPageObject {}
107108
- the user wants built-in waits or `.expect()`
108109
- the control is reused compositionally under selector decorators
109110

110-
If a nested `PageObject` subclass adds a custom constructor, either keep the default constructor shape or implement `cloneWithContext()` explicitly.
111+
Nested `PageObject` instances derive `page` from their current root context. If a nested `PageObject` subclass adds a custom constructor, either keep the default `(root?: Locator, selector?: SelectorType)` shape or implement `cloneWithContext()` explicitly.
111112

112113
### Choose `ListPageObject` when
113114

@@ -191,8 +192,11 @@ class CheckoutPage extends RootPageObject {
191192
When using the built-in classes:
192193

193194
- actions go through `control.$`
195+
- nested `PageObject` instances derive `page` from `root.page()`
194196
- waits and assertions come from `PageObject`
195197
- `ListPageObject` handles repeated child components
198+
- `ListPageObject` indexing/search helpers such as `first()`, `second()`, `at()`, and `getItemByText()` return one item page object
199+
- `ListPageObject` filter helpers such as `filter()`, `filterByText()`, and `filterByTestId()` return a narrower `ListPageObject`, so chain `.first()` or `.at(...)` when one item is needed
196200
- `RootPageObject` is the correct root base class
197201

198202
Examples:
@@ -201,6 +205,7 @@ Examples:
201205
await control.$.click();
202206
await control.expect().toBeVisible();
203207
await items.waitCount(0);
208+
await items.filterByText("Apple").first().expect().toBeVisible();
204209
```
205210

206211
## Fixtures

src/decorators/rootSelectors.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,6 @@ function RootSelectorBy(selector: SelectorType) {
5757
constructor(...args: TArgs) {
5858
const page = resolvePage(args[0], target.name);
5959
super(...args);
60-
this.page = page;
6160
this.root = page.locator("body");
6261
this.selector = selector;
6362
}

src/page-objects/ListPageObject.ts

Lines changed: 36 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { Locator, Page } from "@playwright/test";
1+
import type { Locator } from "@playwright/test";
22
import { PageObject, type SelectorType } from "./PageObject";
33

44
/**
@@ -23,7 +23,6 @@ export class ListPageObject<
2323
protected itemType?:
2424
| TItem
2525
| (new (
26-
page?: Page,
2726
root?: Locator,
2827
selector?: SelectorType,
2928
) => TItem);
@@ -32,23 +31,15 @@ export class ListPageObject<
3231
* @param itemType - PageObject class or instance for each list item.
3332
* - **Class**: Use when items use the default constructor.
3433
* - **Instance**: Use when items need a custom constructor with specific arguments.
35-
* @param page - Playwright page (optional when nested)
3634
* @param root - Root locator (set by decorators)
3735
* @param selector - Selector function (set by decorators)
3836
*/
3937
constructor(
40-
itemType?:
41-
| TItem
42-
| (new (
43-
page?: Page,
44-
root?: Locator,
45-
selector?: SelectorType,
46-
) => TItem),
47-
page?: Page,
38+
itemType?: TItem | (new (root?: Locator, selector?: SelectorType) => TItem),
4839
root?: Locator,
4940
selector?: SelectorType,
5041
) {
51-
super(page, root, selector);
42+
super(root, selector);
5243
this.itemType = itemType;
5344
}
5445

@@ -57,24 +48,22 @@ export class ListPageObject<
5748
itemType?:
5849
| TItem
5950
| (new (
60-
page?: Page,
6151
root?: Locator,
6252
selector?: SelectorType,
6353
) => TItem),
64-
page?: Page,
6554
root?: Locator,
6655
selector?: SelectorType,
6756
) => this;
6857

69-
return new ListPageObjectClass(this.itemType, root.page(), root, selector);
58+
return new ListPageObjectClass(this.itemType, root, selector);
7059
}
7160

7261
/**
7362
* Returns the item at the given index (0-based). Use `-1` for last item.
7463
* @param index - Item index
7564
* @returns PageObject for the item at that index
7665
*/
77-
getItemByIndex(index: number) {
66+
getItemByIndex(index: number): TItem {
7867
return this.resolveItem((p) => p.nth(index));
7968
}
8069

@@ -83,60 +72,58 @@ export class ListPageObject<
8372
* @param index - Item index (0-based; -1 = last, -2 = second-to-last, etc.)
8473
* @returns PageObject for the item at that index
8574
*/
86-
at(index: number) {
75+
at(index: number): TItem {
8776
return this.getItemByIndex(index);
8877
}
8978

9079
/**
9180
* Returns items matching the given Playwright filter options.
9281
* @param options - Playwright locator filter (e.g. `{ hasText: 'foo' }`)
93-
* @returns PageObject for the filtered item(s)
82+
* @returns Narrowed list page object containing the filtered item(s)
9483
*/
95-
filter(options: Parameters<Locator["filter"]>[0]) {
96-
return this.resolveItem((p) => p.filter(options));
84+
filter(options: Parameters<Locator["filter"]>[0]): this {
85+
return this.resolveList((p) => p.filter(options));
9786
}
9887

9988
/**
10089
* Returns items containing the given text.
10190
* @param text - Text to match (string or regex)
102-
* @returns PageObject for the matching item(s)
91+
* @returns Narrowed list page object containing the matching item(s)
10392
*/
104-
filterByText(text: string) {
105-
return this.resolveItem((p) => p.filter({ hasText: text }));
93+
filterByText(text: string | RegExp): this {
94+
return this.filter({ hasText: text });
10695
}
10796

10897
/**
10998
* Returns items that contain an element with the given test id.
110-
* Requires `page` to be set.
11199
* @param id - Test id (string or regex)
112-
* @returns PageObject for the matching item(s)
100+
* @returns Narrowed list page object containing the matching item(s)
113101
*/
114-
filterByTestId(id: string | RegExp) {
115-
const page = this.page;
116-
if (!page) {
117-
throw new Error(
118-
"[ListPageObject] filterByTestId requires page to be set",
119-
);
120-
}
121-
return this.resolveItem((p) => p.filter({ has: page.getByTestId(id) }));
102+
filterByTestId(id: string | RegExp): this {
103+
return this.filter({ has: this.page.getByTestId(id) });
122104
}
123105

124106
/**
125107
* Returns the item whose test id matches the given regex pattern.
126108
* @param mask - Regex pattern string for test id
127109
* @returns PageObject for the matching item
128110
*/
129-
getItemByIdMask(mask: string) {
130-
return this.resolveItem((p) => p.getByTestId(new RegExp(mask)));
111+
getItemByIdMask(mask: string): TItem {
112+
return this.filterByTestId(new RegExp(mask)).first();
131113
}
132114

133115
/** Returns the first item (index 0). */
134-
first() {
116+
first(): TItem {
135117
return this.at(0);
136118
}
137119

120+
/** Returns the second item (index 1). */
121+
second(): TItem {
122+
return this.at(1);
123+
}
124+
138125
/** Returns the last item (index -1). */
139-
last() {
126+
last(): TItem {
140127
return this.at(-1);
141128
}
142129

@@ -145,17 +132,19 @@ export class ListPageObject<
145132
* @param text - Text to match (string or regex)
146133
* @returns PageObject for the matching item
147134
*/
148-
getItemByText(text: string) {
149-
return this.resolveItem((p) => p.getByText(text));
135+
getItemByText(text: string | RegExp): TItem {
136+
return this.filterByText(text).first();
150137
}
151138

152139
/**
153140
* Returns the item matching the given ARIA role and options.
154141
* @param args - Same as {@link Locator.getByRole}
155142
* @returns PageObject for the matching item
156143
*/
157-
getItemByRole(...args: Parameters<Locator["getByRole"]>) {
158-
return this.resolveItem((p) => p.getByRole(...args));
144+
getItemByRole(...args: Parameters<Locator["getByRole"]>): TItem {
145+
return this.resolveList((p) =>
146+
p.filter({ has: p.getByRole(...args) }),
147+
).first();
159148
}
160149

161150
/**
@@ -230,17 +219,21 @@ export class ListPageObject<
230219
return items;
231220
}
232221

222+
protected resolveList(selector: SelectorType): this {
223+
return this.cloneWithContext(this.locator, selector);
224+
}
225+
233226
protected resolveItem(selector: SelectorType): TItem {
234227
if (!this.itemType) {
235-
return new PageObject(this.page, this.locator, selector) as TItem;
228+
return new PageObject(this.locator, selector) as TItem;
236229
}
237230

238231
if (PageObject.isInstance(this.itemType)) {
239232
return this.itemType.cloneWithContext(this.locator, selector) as TItem;
240233
}
241234

242235
if (PageObject.isClass(this.itemType)) {
243-
return new this.itemType(this.page, this.locator, selector);
236+
return new this.itemType(this.locator, selector);
244237
}
245238

246239
return selector(this.locator) as unknown as TItem;

src/page-objects/PageObject.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ export type SelectorType = (p: Locator) => Locator;
1515
*/
1616
export type PageObjectConstructor<TPageObject extends PageObject = PageObject> =
1717
new (
18-
page?: Page,
1918
root?: Locator,
2019
selector?: SelectorType,
2120
) => TPageObject;
@@ -36,16 +35,13 @@ export type PageObjectConstructor<TPageObject extends PageObject = PageObject> =
3635
* ```
3736
*/
3837
export class PageObject {
39-
page?: Page;
4038
root?: Locator;
4139

4240
/**
43-
* @param page - Playwright page (optional when nested)
4441
* @param root - Root locator (set by decorators)
4542
* @param selector - Selector function (set by decorators)
4643
*/
47-
constructor(page?: Page, root?: Locator, selector?: SelectorType) {
48-
this.page = page;
44+
constructor(root?: Locator, selector?: SelectorType) {
4945
this.root = root;
5046
this._selector = selector;
5147
}
@@ -64,6 +60,16 @@ export class PageObject {
6460
return this.locator;
6561
}
6662

63+
get page(): Page {
64+
if (!this.root) {
65+
throw new Error(
66+
`[PageObject] Empty root in ${this.constructor.name}. Cannot resolve page. Maybe "RootSelector" or "Selector" was skipped?`,
67+
);
68+
}
69+
70+
return this.root.page();
71+
}
72+
6773
/**
6874
* Resolved locator for this page object.
6975
* Throws if `RootSelector` or `Selector` decorators were not applied.
@@ -124,7 +130,7 @@ export class PageObject {
124130
*/
125131
cloneWithContext(root: Locator, selector: SelectorType): this {
126132
const PageObjectClass = this.constructor as PageObjectConstructor<this>;
127-
return new PageObjectClass(root.page(), root, selector);
133+
return new PageObjectClass(root, selector);
128134
}
129135

130136
/** Waits for the element to become visible. */

src/page-objects/RootPageObject.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,9 @@ export type RootPageObjectConstructor<
1717
* Use `PageObject` for nested child controls created by selector decorators.
1818
*/
1919
export class RootPageObject extends PageObject {
20-
declare page: Page;
21-
22-
// biome-ignore lint/complexity/noUselessConstructor: constructor enforces the public Page-first root contract
2320
constructor(page: Page) {
24-
super(page);
21+
void page;
22+
super();
2523
}
2624

2725
static isRootClass<TArgs extends [Page, ...unknown[]]>(

0 commit comments

Comments
 (0)