From de8fdb98e6c8584dba1c414ac43c0943b3f75f22 Mon Sep 17 00:00:00 2001 From: Sebastian Fuchs Date: Tue, 29 Sep 2026 14:53:44 +0200 Subject: [PATCH] feat: add `formFiller` to declaratively fill forms in page objects --- .changeset/brave-forms-fill.md | 5 + docs/.vitepress/config.ts | 1 + docs/src/page-object-model/forms.md | 173 ++++++++++++++++++++ src/index.ts | 2 + src/page-object-model/form-filler.test.ts | 186 ++++++++++++++++++++++ src/page-object-model/form-filler.ts | 63 ++++++++ tests/form-filler.spec.ts | 160 +++++++++++++++++++ 7 files changed, 590 insertions(+) create mode 100644 .changeset/brave-forms-fill.md create mode 100644 docs/src/page-object-model/forms.md create mode 100644 src/page-object-model/form-filler.test.ts create mode 100644 src/page-object-model/form-filler.ts create mode 100644 tests/form-filler.spec.ts diff --git a/.changeset/brave-forms-fill.md b/.changeset/brave-forms-fill.md new file mode 100644 index 0000000..da4934e --- /dev/null +++ b/.changeset/brave-forms-fill.md @@ -0,0 +1,5 @@ +--- +"@cronn/playwright-utils": minor +--- + +Add `formFiller` to declaratively fill forms in page objects. diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 3c4586e..4871297 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -53,6 +53,7 @@ export default defineConfig({ text: "Locator Extensions", link: "/page-object-model/locator-extensions", }, + { text: "Forms", link: "/page-object-model/forms" }, ], }, { diff --git a/docs/src/page-object-model/forms.md b/docs/src/page-object-model/forms.md new file mode 100644 index 0000000..21c37e0 --- /dev/null +++ b/docs/src/page-object-model/forms.md @@ -0,0 +1,173 @@ +# Forms + +## Form Filler + +Filling a form in a page object usually turns into a long list of imperative calls, each guarded by a check whether the value was provided at all. `formFiller` replaces this with a declarative mapping: given a `fields` object that maps each data key to a form input, it returns a function that takes a data object and fills every input for which a value is provided. + +### Usage + +```ts +import { formFiller, roleLocators } from "@cronn/playwright-utils"; +import type { Page } from "@playwright/test"; + +function createRegistrationForm(page: Page) { + const { button, textbox } = roleLocators(page); + + return { + fill: formFiller({ + name: textbox("Name"), + email: textbox("Email"), + }), + submitButton: button("Register"), + }; +} +``` + +A test then only describes _what_ to enter, not _how_ to enter it: + +```ts +import { test } from "@playwright/test"; + +test("registers a new user", async ({ page }) => { + const registrationForm = createRegistrationForm(page); + + await registrationForm.fill({ name: "Jane Doe", email: "jane@example.com" }); + await registrationForm.submitButton.click(); +}); +``` + +### Data Type + +By default, the type of the data is inferred from the `fields` object, e.g. `{ name?: string; email?: string }` for the form above. Filler functions (see below) need an explicit parameter type, otherwise their value is `unknown`: + +```ts +formFiller({ + acceptTerms: (accepted: boolean) => + checkbox("Accept terms").setChecked(accepted), +}); +``` + +Alternatively, the type of the data can be set with the type argument, e.g. `formFiller`. The `fields` object is then type-checked against it: every key of the data needs a field, including optional keys, and each field has to accept the type of its value. In return, the parameter types of filler functions are inferred. To declare the fields separately, use the `FormFields` type. + +In both cases, all keys are optional when filling, so a test only has to pass the values that matter for it (see [Optional Values](#optional-values)). + +```ts +interface Registration { + name: string; + email?: string; +} + +const fillRegistration = formFiller({ + name: textbox("Name"), + email: textbox("Email"), +}); +``` + +### Locators and Filler Functions + +A field can be either: + +- a **fillable**, i.e. any object with a `fill(value)` method, such as a Playwright `Locator`, or +- a **filler function** `(value) => Promise` for inputs that aren't filled with `fill`, such as checkboxes, selects or custom widgets. + +```ts +import { formFiller, roleLocators } from "@cronn/playwright-utils"; +import type { Page } from "@playwright/test"; + +interface Registration { + name: string; + country?: string; + acceptTerms?: boolean; +} + +function createRegistrationForm(page: Page) { + const { checkbox, combobox, textbox } = roleLocators(page); + + return { + fill: formFiller({ + name: textbox("Name"), + country: (country) => combobox("Country").selectOption(country), + acceptTerms: (accepted) => checkbox("Accept terms").setChecked(accepted), + }), + }; +} +``` + +### Optional Values + +Fields whose value is `undefined` are skipped, so a test can pass only the values that matter for it and leave all other inputs untouched: + +```ts +await registrationForm.fill({ name: "Jane Doe" }); +``` + +Falsy values such as `""`, `0` or `false` are still filled. For example, `acceptTerms: false` unchecks the checkbox. + +### Fill Order + +Inputs are filled one after another, in the order of the keys in the `fields` object. The order of the keys in the data object doesn't matter. This helps with dependent inputs, e.g. a state select that is only populated after a country was selected: + +```ts +formFiller
({ + country: (country) => combobox("Country").selectOption(country), + state: (state) => combobox("State").selectOption(state), +}); +``` + +### Nested Data + +The function returned by `formFiller` is a filler function itself. When the data contains nested objects, a nested `formFiller` can therefore be used as a field. Within a form with explicit data type, the type of the nested data is taken from the enclosing form: + +```ts +interface Registration { + name: string; + address?: Address; +} + +interface Address { + street?: string; + city?: string; +} + +function createRegistrationForm(page: Page) { + const { textbox } = roleLocators(page); + + return { + fill: formFiller({ + name: textbox("Name"), + address: formFiller({ + street: textbox("Street"), + city: textbox("City"), + }), + }), + }; +} +``` + +If the nested part of the form is a page object on its own, it can be used as a field directly, because its `fill` method makes it fillable: + +```ts +import type { Locator } from "@playwright/test"; + +function createAddressForm(container: Locator) { + const { textbox } = roleLocators(container); + + return { + fill: formFiller
({ + street: textbox("Street"), + city: textbox("City"), + }), + }; +} + +function createRegistrationForm(page: Page) { + const { group, textbox } = roleLocators(page); + + return { + fill: formFiller({ + name: textbox("Name"), + address: createAddressForm(group("Address")), + }), + }; +} +``` diff --git a/src/index.ts b/src/index.ts index ecfcc8f..ded9955 100644 --- a/src/index.ts +++ b/src/index.ts @@ -36,6 +36,8 @@ export { export { resolveFromPackageRoot } from "./file"; +export { formFiller, type FormFields } from "./page-object-model/form-filler"; + export { extendLocator, type ExtendedLocator, diff --git a/src/page-object-model/form-filler.test.ts b/src/page-object-model/form-filler.test.ts new file mode 100644 index 0000000..1bbd96c --- /dev/null +++ b/src/page-object-model/form-filler.test.ts @@ -0,0 +1,186 @@ +import { expect, expectTypeOf, test, vi } from "vitest"; + +import { formFiller } from "./form-filler"; + +interface Registration { + name?: string; + age?: number; + newsletter?: boolean; +} + +interface Address { + street?: string; + city?: string; +} + +function createFiller() { + return vi.fn<(value: TValue) => Promise>().mockResolvedValue(); +} + +function createRecordingFiller(calls: Array, name: string) { + return vi.fn(() => { + calls.push(name); + return Promise.resolve(); + }); +} + +test("calls fill on fillable fields", async () => { + const name = { fill: createFiller() }; + + await formFiller({ + name, + age: createFiller(), + newsletter: createFiller(), + })({ + name: "Jane", + }); + + expect(name.fill).toHaveBeenCalledExactlyOnceWith("Jane"); +}); + +test("calls filler functions", async () => { + const name = createFiller(); + + await formFiller<{ name: string }>({ name })({ name: "Jane" }); + + expect(name).toHaveBeenCalledExactlyOnceWith("Jane"); +}); + +test("skips fields with undefined values", async () => { + const name = createFiller(); + const age = createFiller(); + + await formFiller({ name, age, newsletter: createFiller() })({ + age: 42, + }); + + expect(name).not.toHaveBeenCalled(); + expect(age).toHaveBeenCalledExactlyOnceWith(42); +}); + +test("fills falsy values", async () => { + const name = createFiller(); + const age = createFiller(); + const newsletter = createFiller(); + + await formFiller({ name, age, newsletter })({ + name: "", + age: 0, + newsletter: false, + }); + + expect(name).toHaveBeenCalledExactlyOnceWith(""); + expect(age).toHaveBeenCalledExactlyOnceWith(0); + expect(newsletter).toHaveBeenCalledExactlyOnceWith(false); +}); + +test("fills fields in the order of the fields object", async () => { + const calls: Array = []; + + await formFiller({ + newsletter: createRecordingFiller(calls, "newsletter"), + name: createRecordingFiller(calls, "name"), + age: createRecordingFiller(calls, "age"), + })({ name: "Jane", age: 42, newsletter: true }); + + expect(calls).toEqual(["newsletter", "name", "age"]); +}); + +test("waits for each field before filling the next one", async () => { + const calls: Array = []; + + await formFiller({ + name: async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + calls.push("name"); + }, + age: createRecordingFiller(calls, "age"), + newsletter: createFiller(), + })({ name: "Jane", age: 42 }); + + expect(calls).toEqual(["name", "age"]); +}); + +test("can be called multiple times", async () => { + const name = createFiller(); + const fill = formFiller<{ name: string }>({ name }); + + await fill({ name: "Jane" }); + await fill({ name: "John" }); + + expect(name).toHaveBeenNthCalledWith(1, "Jane"); + expect(name).toHaveBeenNthCalledWith(2, "John"); +}); + +test("supports nested form fillers", async () => { + const street = createFiller(); + const city = createFiller(); + + await formFiller<{ address: Address }>({ + address: formFiller({ street, city }), + })({ address: { street: "Main Street 1", city: "Springfield" } }); + + expect(street).toHaveBeenCalledExactlyOnceWith("Main Street 1"); + expect(city).toHaveBeenCalledExactlyOnceWith("Springfield"); +}); + +test("infers the data type from the fields", async () => { + const name = { fill: createFiller() }; + const age = createFiller(); + const street = createFiller(); + + const fill = formFiller({ + name, + age, + address: formFiller({ street }), + }); + await fill({ name: "Jane", address: { street: "Main Street 1" } }); + + expectTypeOf(fill).parameter(0).toEqualTypeOf<{ + name?: string; + age?: number; + address?: { street?: string }; + }>(); + expect(name.fill).toHaveBeenCalledExactlyOnceWith("Jane"); + expect(age).not.toHaveBeenCalled(); + expect(street).toHaveBeenCalledExactlyOnceWith("Main Street 1"); +}); + +test("rejects invalid fields and data on type level", () => { + const fill = formFiller({ name: createFiller() }); + // @ts-expect-error -- value doesn't match the type of the field + void fill({ name: 42 }); + + formFiller({ + // @ts-expect-error -- value of filler function without parameter type is unknown + name: (name) => createFiller()(name), + }); + + // @ts-expect-error -- field missing for a required key of the explicit data type + formFiller<{ name: string; age: number }>({ name: createFiller() }); + + // @ts-expect-error -- field missing for an optional key of the explicit data type + formFiller({ name: createFiller(), age: createFiller() }); +}); + +test("accepts partial data for an explicit data type", async () => { + const name = createFiller(); + + const fill = formFiller<{ name: string }>({ name }); + await fill({}); + + expectTypeOf(fill).parameter(0).toEqualTypeOf<{ name?: string }>(); + expect(name).not.toHaveBeenCalled(); +}); + +test("infers filler function parameters of nested form fillers", async () => { + const newsletter = createFiller(); + + await formFiller<{ settings: { newsletter?: boolean } }>({ + settings: formFiller({ + newsletter: (subscribe) => newsletter(subscribe), + }), + })({ settings: { newsletter: true } }); + + expect(newsletter).toHaveBeenCalledExactlyOnceWith(true); +}); diff --git a/src/page-object-model/form-filler.ts b/src/page-object-model/form-filler.ts new file mode 100644 index 0000000..8f74a97 --- /dev/null +++ b/src/page-object-model/form-filler.ts @@ -0,0 +1,63 @@ +interface Fillable { + fill: Filler; +} + +type Filler = (value: TValue) => Promise; + +export type FormFields = { + [K in keyof TData]-?: + | Fillable> + | Filler>; +}; + +/** + * Creates a filler function that fills a form declaratively from a data + * object, e.g. as the `fill` method of a page object. + * + * Each key of `fields` maps a key of the data to a form input: either an + * object with a `fill` method (such as a `Locator` or a nested page object) or + * a filler function receiving the value. Fields are filled one after another + * in the order of the keys in `fields`, and fields whose value is `undefined` + * are skipped. + * + * The type of the data is either given explicitly as type argument or inferred + * from `fields`. When inferred, filler functions need an explicit parameter + * type, otherwise their value is `unknown`. In both cases, the returned + * function accepts partial data. + * + * As the returned function is a filler itself, it can be used as a field of + * another form filler to fill nested data. + * + * @param fields - Mapping from each key of the data to the input to fill + * @returns A function filling the form with the given data + * @example + * ```ts + * const { checkbox, textbox } = roleLocators(page); + * + * const registrationForm = { + * fill: formFiller({ + * name: textbox("Name"), + * acceptTerms: (accepted) => checkbox("Accept terms").setChecked(accepted), + * }), + * }; + * ``` + */ +export function formFiller( + fields: FormFields, +): Filler> { + return async (data) => { + for (const [key, filler] of Object.entries( + fields as Record | Filler>, + )) { + const value = (data as Record)[key]; + if (value === undefined) { + continue; + } + if (typeof filler === "function") { + await filler(value); + } else { + await filler.fill(value); + } + } + }; +} diff --git a/tests/form-filler.spec.ts b/tests/form-filler.spec.ts new file mode 100644 index 0000000..007811c --- /dev/null +++ b/tests/form-filler.spec.ts @@ -0,0 +1,160 @@ +import { type Locator, type Page, test } from "@playwright/test"; + +import { formFiller, roleLocators } from "../src"; +import { expect } from "../src/test/fixtures"; +import { html } from "../src/test/utils"; + +interface Registration { + name?: string; + email?: string; + country?: string; + acceptTerms?: boolean; + address?: Address; +} + +interface Address { + street?: string; + city?: string; +} + +async function setupTest(page: Page): Promise { + await page.setContent(html` +
+ + + +
+ Address + + +
+ +
+ `); +} + +function createRegistrationForm(page: Page) { + const { checkbox, combobox, textbox } = roleLocators(page); + + return { + fill: formFiller({ + name: textbox("Name"), + email: textbox("Email"), + country: async (country) => { + await combobox("Country").selectOption(country); + }, + address: formFiller({ + street: textbox("Street"), + city: textbox("City"), + }), + acceptTerms: (accepted) => checkbox("Accept terms").setChecked(accepted), + }), + }; +} + +test("fills inputs using locators and filler functions", async ({ page }) => { + await setupTest(page); + const { checkbox, combobox, textbox } = roleLocators(page); + + await createRegistrationForm(page).fill({ + name: "Jane Doe", + email: "jane@example.com", + country: "Germany", + acceptTerms: true, + }); + + await expect(textbox("Name")).toHaveValue("Jane Doe"); + await expect(textbox("Email")).toHaveValue("jane@example.com"); + await expect(combobox("Country")).toHaveValue("Germany"); + await expect(checkbox("Accept terms")).toBeChecked(); +}); + +test("fills nested data using a nested form", async ({ page }) => { + await setupTest(page); + const { textbox } = roleLocators(page); + + await createRegistrationForm(page).fill({ + address: { street: "Main Street 1", city: "Springfield" }, + }); + + await expect(textbox("Street")).toHaveValue("Main Street 1"); + await expect(textbox("City")).toHaveValue("Springfield"); +}); + +test("leaves inputs with undefined values untouched", async ({ page }) => { + await setupTest(page); + const { checkbox, textbox } = roleLocators(page); + await textbox("Email").fill("prefilled@example.com"); + + await createRegistrationForm(page).fill({ + name: "Jane Doe", + address: { city: "Springfield" }, + }); + + await expect(textbox("Name")).toHaveValue("Jane Doe"); + await expect(textbox("Email")).toHaveValue("prefilled@example.com"); + await expect(textbox("Street")).toHaveValue(""); + await expect(textbox("City")).toHaveValue("Springfield"); + await expect(checkbox("Accept terms")).not.toBeChecked(); +}); + +test("fills nested data using a fillable page object", async ({ page }) => { + await setupTest(page); + const { group, textbox } = roleLocators(page); + + function createAddressForm(container: Locator) { + const addressLocators = roleLocators(container); + + return { + fill: formFiller
({ + street: addressLocators.textbox("Street"), + city: addressLocators.textbox("City"), + }), + }; + } + + const fillRegistration = formFiller>({ + address: createAddressForm(group("Address")), + }); + + await fillRegistration({ + address: { street: "Main Street 1", city: "Springfield" }, + }); + + await expect(textbox("Street")).toHaveValue("Main Street 1"); + await expect(textbox("City")).toHaveValue("Springfield"); +}); + +test("fills inputs using a form with inferred data type", async ({ page }) => { + await setupTest(page); + const { checkbox, combobox, textbox } = roleLocators(page); + + const fillRegistration = formFiller({ + name: textbox("Name"), + country: async (country: string) => { + await combobox("Country").selectOption(country); + }, + address: formFiller({ city: textbox("City") }), + acceptTerms: (accepted: boolean) => + checkbox("Accept terms").setChecked(accepted), + }); + + await fillRegistration({ + name: "Jane Doe", + country: "France", + address: { city: "Springfield" }, + acceptTerms: true, + }); + + await expect(textbox("Name")).toHaveValue("Jane Doe"); + await expect(combobox("Country")).toHaveValue("France"); + await expect(textbox("City")).toHaveValue("Springfield"); + await expect(checkbox("Accept terms")).toBeChecked(); +});