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
5 changes: 5 additions & 0 deletions .changeset/extend-locator.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cronn/playwright-utils": minor
---

Add `extendLocator` to extend a `Locator` with custom properties and functions while retaining the `Locator` API.
32 changes: 32 additions & 0 deletions docs/src/page-object-model/locator-extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
6 changes: 5 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,14 @@ export {

export { resolveFromPackageRoot } from "./file";

export {
extendLocator,
type ExtendedLocator,
} from "./page-object-model/extend-locator";
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";
Expand Down
50 changes: 50 additions & 0 deletions src/page-object-model/extend-locator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import type { Locator } from "@playwright/test";

// `toString` is excluded, since every object has it
type WithoutLocatorKeys = Partial<
Record<Exclude<keyof Locator, "toString">, never>
>;

export type ExtendedLocator<TExtensions extends WithoutLocatorKeys> = 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<TExtensions extends object & WithoutLocatorKeys>(
locator: Locator,
createExtensions: (locator: Locator) => TExtensions,
): ExtendedLocator<TExtensions> {
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>) => unknown).bind(target)
: value;
},
has(target, property) {
return isOwnKeyOf(extensions, property) || Reflect.has(target, property);
},
}) as ExtendedLocator<TExtensions>;
}

function isOwnKeyOf<T extends object>(
object: T,
key: PropertyKey,
): key is keyof T {
return Object.hasOwn(object, key);
}
82 changes: 82 additions & 0 deletions tests/extend-locator.spec.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
await page.setContent(content);
}

test("provides custom properties and functions", async ({ page }) => {
await setupTest(page, html`<input aria-label="Name" />`);

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`<button>Save</button>`);

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`
<button>Save</button>
<button>Cancel</button>
`,
);

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();
});
Loading