Skip to content

Commit 1e52f62

Browse files
Merge pull request #3 from sergeyshmakov/2026-03-18-decoupling-decorators
2026 03 18 decoupling decorators
2 parents 8536c41 + bfc9daf commit 1e52f62

30 files changed

+1890
-515
lines changed

.github/dependabot.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ updates:
88
day: "monday"
99
time: "06:00"
1010
timezone: "UTC"
11+
allow:
12+
- dependency-type: "direct"
1113
open-pull-requests-limit: 5
1214
labels:
1315
- "dependencies"

.lintstagedrc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
{
2-
"*.{ts,tsx,js,jsx,json}": "npx @biomejs/biome check --write"
2+
"*.{js,jsx,ts,tsx,cjs,mjs,cts,mts,json,jsonc,md}": "biome check --write --no-errors-on-unmatched"
33
}

README.md

Lines changed: 302 additions & 171 deletions
Large diffs are not rendered by default.
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { test } from "./fixtures";
2+
3+
/**
4+
* E2E tests demonstrating external controls (not extending PageObject)
5+
* used via trailing constructor/factory decorator metadata.
6+
*/
7+
8+
test.beforeEach(async ({ page }) => {
9+
await page.goto("/");
10+
});
11+
12+
test("constructor/factory metadata — PromoCode fills and Apply button clicks via external controls", async ({
13+
externalCheckoutPage,
14+
}) => {
15+
// PromoCode uses ExternalInputControl as the trailing decorator argument.
16+
await externalCheckoutPage.PromoCode.fill("SAVE20");
17+
18+
// ApplyPromoButton uses a trailing factory function.
19+
await externalCheckoutPage.ApplyPromoButton.locator.click();
20+
});
21+
22+
test("constructor metadata — ExternalButtonControl is constructed with the resolved locator", async ({
23+
externalCheckoutPage,
24+
}) => {
25+
// We use .first() since there are multiple Remove buttons on the page.
26+
await externalCheckoutPage.FirstRemoveButton.locator
27+
.first()
28+
.waitFor({ state: "visible" });
29+
await externalCheckoutPage.FirstRemoveButton.locator.first().click();
30+
});
31+
32+
test("applyPromoCode helper uses both external control creation paths", async ({
33+
externalCheckoutPage,
34+
}) => {
35+
await externalCheckoutPage.applyPromoCode("DISCOUNT10");
36+
});

example/e2e/fixtures.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
import { test as base } from "@playwright/test";
22
import { createFixtures } from "playwright-page-object";
33
import { CheckoutPage } from "./page-objects/CheckoutPage";
4+
import { ExternalCheckoutPage } from "./page-objects/ExternalCheckoutPage";
45

5-
export const test = base.extend<{ checkoutPage: CheckoutPage }>(
6+
export const test = base.extend<{
7+
checkoutPage: CheckoutPage;
8+
externalCheckoutPage: ExternalCheckoutPage;
9+
}>(
610
createFixtures({
711
checkoutPage: CheckoutPage,
12+
externalCheckoutPage: ExternalCheckoutPage,
813
}),
914
);

example/e2e/page-objects/CheckoutPage.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
ListPageObject,
44
ListStrictSelector,
55
PageObject,
6+
RootPageObject,
67
RootSelector,
78
Selector,
89
SelectorByRole,
@@ -11,7 +12,7 @@ import { CartItemControl } from "./CartItemControl";
1112
import { ButtonControl } from "./controls/ButtonControl";
1213

1314
@RootSelector("CheckoutPage")
14-
export class CheckoutPage extends PageObject {
15+
export class CheckoutPage extends RootPageObject {
1516
/** PageObject approach: use PromoCode.$.fill() */
1617
@Selector("PromoCodeInput")
1718
accessor PromoCode = new PageObject();
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import type { Page } from "@playwright/test";
2+
import { RootSelector, Selector, SelectorByRole } from "playwright-page-object";
3+
import { ExternalButtonControl } from "./controls/ExternalButtonControl";
4+
import { ExternalInputControl } from "./controls/ExternalInputControl";
5+
6+
/**
7+
* Demonstrates a root page object that does NOT extend `PageObject`.
8+
*
9+
* The class has the standard Playwright POM constructor `(page: Page)`.
10+
* `@RootSelector` wires the page object context automatically, so `this.page`
11+
* is fully preserved on the class itself.
12+
*
13+
* Child selector decorators then construct external controls from the resolved
14+
* locator using the trailing constructor/factory argument.
15+
*
16+
* Two child control patterns shown:
17+
* 1. Constructor as the last decorator arg
18+
* 2. Factory function as the last decorator arg
19+
*/
20+
@RootSelector("CheckoutPage")
21+
export class ExternalCheckoutPage {
22+
constructor(readonly page: Page) {}
23+
24+
/**
25+
* Constructor signature.
26+
* `new ExternalInputControl(resolvedLocator)` is called at access time.
27+
*/
28+
@Selector("PromoCodeInput", ExternalInputControl)
29+
accessor PromoCode = undefined as unknown as ExternalInputControl;
30+
31+
/**
32+
* Factory function signature.
33+
* Useful when the construction logic should stay inline with the selector.
34+
*/
35+
@SelectorByRole(
36+
"button",
37+
{ name: "Apply" },
38+
(locator) => new ExternalButtonControl(locator),
39+
)
40+
accessor ApplyPromoButton = undefined as unknown as ExternalButtonControl;
41+
42+
/**
43+
* Constructor signature again, with a different selector.
44+
*/
45+
@SelectorByRole("button", { name: "Remove" }, ExternalButtonControl)
46+
accessor FirstRemoveButton = undefined as unknown as ExternalButtonControl;
47+
48+
async applyPromoCode(code: string) {
49+
await this.PromoCode.fill(code);
50+
await this.ApplyPromoButton.locator.click();
51+
}
52+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import type { Locator } from "@playwright/test";
2+
3+
/**
4+
* An example of an external control that does NOT extend `PageObject`.
5+
* The selector decorator constructs it with the resolved locator.
6+
*/
7+
export class ExternalButtonControl {
8+
constructor(private readonly _locator: Locator) {}
9+
10+
/** The resolved Playwright locator for this button. */
11+
get locator(): Locator {
12+
return this._locator;
13+
}
14+
15+
async click() {
16+
await this.locator.click();
17+
}
18+
19+
async expectVisible() {
20+
const { expect } = await import("@playwright/test");
21+
await expect(this.locator).toBeVisible();
22+
}
23+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import type { Locator } from "@playwright/test";
2+
3+
/**
4+
* An external input control that accepts the locator as a constructor argument.
5+
* Compatible with selector decorators that pass the constructor as metadata.
6+
* Does NOT extend `PageObject`.
7+
*/
8+
export class ExternalInputControl {
9+
constructor(private readonly _locator: Locator) {}
10+
11+
get locator(): Locator {
12+
return this._locator;
13+
}
14+
15+
async fill(value: string) {
16+
await this._locator.fill(value);
17+
}
18+
19+
async expectVisible() {
20+
const { expect } = await import("@playwright/test");
21+
await expect(this._locator).toBeVisible();
22+
}
23+
}

example/package-lock.json

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

0 commit comments

Comments
 (0)