From 8f9985aa2fb2f6b8deb49044d6b1e89ee880b66e Mon Sep 17 00:00:00 2001 From: Sebastian Fuchs Date: Fri, 2 Oct 2026 08:04:33 +0200 Subject: [PATCH 1/2] refactor: Rename `roleLocators` file --- src/index.ts | 2 +- .../{locator-extensions.ts => role-locators.ts} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename src/page-object-model/{locator-extensions.ts => role-locators.ts} (100%) diff --git a/src/index.ts b/src/index.ts index 928dc8f..bc79393 100644 --- a/src/index.ts +++ b/src/index.ts @@ -39,7 +39,7 @@ export { resolveFromPackageRoot } from "./file"; export { roleLocators, type RoleLocators, -} from "./page-object-model/locator-extensions"; +} from "./page-object-model/role-locators"; export { maskBaseURL } from "./normalizers/mask-base-url"; export { maskedValue, maskedValueWithIndex } from "./normalizers/masked-value"; diff --git a/src/page-object-model/locator-extensions.ts b/src/page-object-model/role-locators.ts similarity index 100% rename from src/page-object-model/locator-extensions.ts rename to src/page-object-model/role-locators.ts From c5f1390a6b27ec8a13883174d1e271566aa7cd1f Mon Sep 17 00:00:00 2001 From: Sebastian Fuchs Date: Fri, 2 Oct 2026 08:41:59 +0200 Subject: [PATCH 2/2] feat: Add `extendLocator` to extend locators with custom properties and functions --- .changeset/extend-locator.md | 5 ++ .../page-object-model/locator-extensions.md | 32 ++++++++ src/index.ts | 4 + src/page-object-model/extend-locator.ts | 50 +++++++++++ tests/extend-locator.spec.ts | 82 +++++++++++++++++++ 5 files changed, 173 insertions(+) create mode 100644 .changeset/extend-locator.md create mode 100644 src/page-object-model/extend-locator.ts create mode 100644 tests/extend-locator.spec.ts diff --git a/.changeset/extend-locator.md b/.changeset/extend-locator.md new file mode 100644 index 0000000..58c7429 --- /dev/null +++ b/.changeset/extend-locator.md @@ -0,0 +1,5 @@ +--- +"@cronn/playwright-utils": minor +--- + +Add `extendLocator` to extend a `Locator` with custom properties and functions while retaining the `Locator` API. diff --git a/docs/src/page-object-model/locator-extensions.md b/docs/src/page-object-model/locator-extensions.md index 262094a..76faa31 100644 --- a/docs/src/page-object-model/locator-extensions.md +++ b/docs/src/page-object-model/locator-extensions.md @@ -67,3 +67,35 @@ const { tab } = roleLocators(page); await expect(tab()).toHaveCount(3); await expect(tab({ disabled: true })).toBeVisible(); ``` + +## Extended Locators + +Page objects often need element-specific helpers, such as filling a composite input or reading a parsed value. `extendLocator` adds custom properties and functions to a `Locator` while keeping the full `Locator` API, so the result can still be clicked, combined with other locators and passed to `expect`. + +### Usage + +```ts +import { extendLocator, roleLocators } from "@cronn/playwright-utils"; +import { expect, test } from "@playwright/test"; + +test("selects a date", async ({ page }) => { + const { textbox } = roleLocators(page); + + const datePicker = extendLocator(textbox("Date"), (locator) => ({ + async selectDate(date: Date) { + await locator.fill(date.toISOString().slice(0, 10)); + }, + })); + + await datePicker.selectDate(new Date("2026-10-02")); + + await expect(datePicker).toHaveValue("2026-10-02"); +}); +``` + +`createExtensions` receives the original locator and is called once. Extensions must not override members of `Locator`, which is enforced at the type level. + +### Limitations + +- Locators derived from an extended locator, e.g. via `first()`, `filter()` or `locator()`, are plain `Locator`s without the extensions. +- Methods of `Locator` return a new function instance on every access, so comparing them by identity fails (`extended.click !== extended.click`). diff --git a/src/index.ts b/src/index.ts index bc79393..ecfcc8f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -36,6 +36,10 @@ export { export { resolveFromPackageRoot } from "./file"; +export { + extendLocator, + type ExtendedLocator, +} from "./page-object-model/extend-locator"; export { roleLocators, type RoleLocators, diff --git a/src/page-object-model/extend-locator.ts b/src/page-object-model/extend-locator.ts new file mode 100644 index 0000000..9dc6c16 --- /dev/null +++ b/src/page-object-model/extend-locator.ts @@ -0,0 +1,50 @@ +import type { Locator } from "@playwright/test"; + +// `toString` is excluded, since every object has it +type WithoutLocatorKeys = Partial< + Record, never> +>; + +export type ExtendedLocator = Locator & + TExtensions; + +/** + * Extends a `Locator` with custom properties and functions while retaining the full `Locator` API. + * + * Extensions must not override members of `Locator`. Locators derived from the extended locator + * (e.g. via `first()`, `filter()` or `locator()`) are plain `Locator`s without the extensions. + * + * Methods of `Locator` are bound to the original locator on every access, so accessing the same method + * twice returns different function instances (e.g. `extended.click !== extended.click`). + * + * @param locator The locator to extend + * @param createExtensions Creates the extensions, receiving the original locator + */ +export function extendLocator( + locator: Locator, + createExtensions: (locator: Locator) => TExtensions, +): ExtendedLocator { + const extensions = createExtensions(locator); + + return new Proxy(locator, { + get(target, property) { + if (isOwnKeyOf(extensions, property)) { + return extensions[property]; + } + const value: unknown = Reflect.get(target, property, target); + return typeof value === "function" && property !== "constructor" + ? (value as (...args: Array) => unknown).bind(target) + : value; + }, + has(target, property) { + return isOwnKeyOf(extensions, property) || Reflect.has(target, property); + }, + }) as ExtendedLocator; +} + +function isOwnKeyOf( + object: T, + key: PropertyKey, +): key is keyof T { + return Object.hasOwn(object, key); +} diff --git a/tests/extend-locator.spec.ts b/tests/extend-locator.spec.ts new file mode 100644 index 0000000..735e7b4 --- /dev/null +++ b/tests/extend-locator.spec.ts @@ -0,0 +1,82 @@ +import { type Page, test } from "@playwright/test"; + +import { type ExtendedLocator, extendLocator } from "../src"; +import { expect } from "../src/test/fixtures"; +import { html } from "../src/test/utils"; + +async function setupTest(page: Page, content: string): Promise { + await page.setContent(content); +} + +test("provides custom properties and functions", async ({ page }) => { + await setupTest(page, html``); + + const textbox = extendLocator(page.getByRole("textbox"), (locator) => ({ + label: "Name", + async fillTwice(value: string) { + await locator.fill(`${value}${value}`); + }, + })); + + await textbox.fillTwice("ab"); + + expect(textbox.label).toBe("Name"); + await expect(textbox).toHaveValue("abab"); +}); + +test("retains the Locator API", async ({ page }) => { + await setupTest(page, html``); + + const locator = page.getByRole("button", { name: "Save" }); + const button = extendLocator(locator, () => ({ name: "Save" })); + + await button.click(); + + await expect(button).toBeVisible(); + await expect(button).toHaveText("Save"); + expect(button instanceof locator.constructor).toBe(true); + expect(button.constructor).toBe(locator.constructor); + expect(String(button)).toBe(String(locator)); +}); + +test("can be combined with other locators", async ({ page }) => { + await setupTest( + page, + html` + + + `, + ); + + const save = extendLocator(page.getByText("Save"), () => ({})); + + await expect(page.getByText("Cancel").or(save)).toHaveCount(2); + await expect(page.getByRole("button").and(save)).toHaveCount(1); +}); + +test("supports the in operator for extensions and Locator members", ({ + page, +}) => { + const extended = extendLocator(page.locator("body"), () => ({ custom: 1 })); + + expect("custom" in extended).toBe(true); + expect("click" in extended).toBe(true); + expect("unknown" in extended).toBe(false); +}); + +test("rejects extensions overriding Locator members", ({ page }) => { + // @ts-expect-error -- `click` is a member of Locator + extendLocator(page.locator("body"), () => ({ click: "override" })); + + // @ts-expect-error -- `click` is a member of Locator + extendLocator<{ click: string }>(page.locator("body"), () => ({ + click: "override", + })); +}); + +test("rejects ExtendedLocator types overriding Locator members", () => { + // @ts-expect-error -- `click` is a member of Locator + const invalid: ExtendedLocator<{ click: string }> | undefined = undefined; + + expect(invalid).toBeUndefined(); +});