UNPKG

playwright-cucumber-ts-steps

Version:

A collection of reusable Playwright step definitions for Cucumber in TypeScript, designed to streamline end-to-end testing across web, API, and mobile applications.

1,145 lines (1,075 loc) 41.1 kB
import { When, setDefaultTimeout } from "@cucumber/cucumber"; import type { DataTable } from "@cucumber/cucumber"; import { Locator, devices, BrowserContextOptions } from "@playwright/test"; import dayjs from "dayjs"; import { parseClickOptions, parseCheckOptions, parseFillOptions, parseHoverOptions, parseUncheckOptions, } from "../helpers/utils/optionsUtils"; // Assuming this path is correct import { normalizeDeviceName } from "../helpers/utils/resolveUtils"; // Assuming this path is correct import { CustomWorld } from "../helpers/world"; // Assuming this path is correct // =================================================================================== // UTILITY ACTIONS: TIMERS // =================================================================================== /** * Enables fake timers for the current page, fixing the time at the moment this step is executed. * This is useful for testing time-dependent UI components without actual time passing. * * ```gherkin * When I use fake timers * ``` * * @example * When I use fake timers * And I go to "/countdown-page" * When I advance timers by 1000 milliseconds * Then I should see text "9 seconds remaining" * * @remarks * This step uses Playwright's `page.clock.setFixedTime()` to control the browser's internal * clock. All subsequent time-related operations (like `setTimeout`, `setInterval`, `Date.now()`) * will operate based on this fixed time. Use {@link When_I_advance_timers_by_milliseconds | "When I advance timers by X milliseconds"} * or {@link When_I_advance_timers_by_seconds | "When I advance timers by X seconds"} to progress time. * To revert, use {@link When_I_use_real_timers | "When I use real timers"}. * @category Timer Steps */ export async function When_I_use_fake_timers(this: CustomWorld) { const initialTime = Date.now(); await this.page.clock.setFixedTime(initialTime); this.fakeTimersActive = true; // Assuming CustomWorld has a fakeTimersActive property this.log?.(`⏱️ Fake timers enabled, fixed at ${new Date(initialTime).toISOString()}`); } When(/^I use fake timers$/, When_I_use_fake_timers); /** * Restores real timers for the current page, releasing control over the browser's internal clock. * * ```gherkin * When I use real timers * ``` * * @example * When I use fake timers * When I advance timers by 10 seconds * When I use real timers * * @remarks * This step uses Playwright's `page.clock.useRealTimers()`. After this step, `setTimeout`, `setInterval`, * and other time-related functions will behave normally, using the system's real time. * @category Timer Steps */ export async function When_I_use_real_timers(this: CustomWorld) { // FIX: Use 'as any' to tell TypeScript that this property exists at runtime. // This is a common workaround for experimental/plugin-like APIs that aren't fully typed. await (this.page.clock as any).useRealTimers(); this.fakeTimersActive = false; this.log?.(`⏱️ Real timers restored.`); } When(/^I use real timers$/, When_I_use_real_timers); export async function When_I_advance_timers_by_milliseconds(this: CustomWorld, ms: number) { if (this.fakeTimersActive) { // FIX: Use 'as any' for tick() await (this.page.clock as any).tick(ms); this.log?.(`⏱️ Advanced fake timers by ${ms} milliseconds.`); } else { this.log?.("⚠️ Real timers are active. `When I advance timers by...` has no effect."); } } When(/^I advance timers by (\d+) milliseconds$/, When_I_advance_timers_by_milliseconds); // This line remains unchanged /** * Advances fake timers by the given number of seconds. Requires fake timers to be enabled. * * ```gherkin * When I advance timers by {int} seconds * ``` * * @param seconds - The number of seconds to advance the fake clock by. * * @example * When I use fake timers * When I advance timers by 2 seconds * * @remarks * This step converts seconds to milliseconds and uses Playwright's `page.clock.tick()`. * It will only have an effect if {@link When_I_use_fake_timers | "When I use fake timers"} * has been called previously. If real timers are active, a warning will be logged. * @category Timer Steps */ export async function When_I_advance_timers_by_seconds(this: CustomWorld, seconds: number) { const ms = seconds * 1000; if (this.fakeTimersActive) { // FIX: Use 'as any' for tick() await (this.page.clock as any).tick(ms); this.log?.(`⏱️ Advanced fake timers by ${seconds} seconds.`); } else { this.log?.("⚠️ Real timers are active. `When I advance timers by...` has no effect."); } } // This line remains unchanged, as it's just the Cucumber step definition linking to the function // When(/^I advance timers by (\d+) seconds$/, When_I_advance_timers_by_seconds); /** * Waits for the given number of seconds using `setTimeout`. This is a real-time wait. * * ```gherkin * When I wait {int} second[s] * ``` * * @param seconds - The number of seconds to wait. * * @example * When I wait 3 seconds * * @remarks * This step pauses test execution for the specified duration using Node.js `setTimeout`. * It's generally preferred to use explicit waits for element conditions (e.g., `toBeVisible`) * over arbitrary waits, but this can be useful for debugging or waiting for external factors. * @category General Action Steps */ export async function When_I_wait_seconds(seconds: number) { await new Promise((resolve) => setTimeout(resolve, seconds * 1000)); } When(/^I wait (\d+) second[s]?$/, When_I_wait_seconds); /** * Waits for the given number of milliseconds using `setTimeout`. This is a real-time wait. * * ```gherkin * When I wait {int} millisecond[s] * ``` * * @param ms - The number of milliseconds to wait. * * @example * When I wait 500 milliseconds * * @remarks * This step pauses test execution for the specified duration using Node.js `setTimeout`. * It's generally preferred to use explicit waits for element conditions (e.g., `toBeVisible`) * over arbitrary waits, but this can be useful for debugging or waiting for external factors. * @category General Action Steps */ export async function When_I_wait_milliseconds(this: CustomWorld, ms: number) { await new Promise((res) => setTimeout(res, ms)); } When(/^I wait (\d+) millisecond[s]?$/, When_I_wait_milliseconds); /** * Sets the default step timeout for all subsequent Cucumber steps. * This can override the global timeout set in `cucumber.js` configuration. * * ```gherkin * When I set step timeout to {int} ms * ``` * * @param timeoutMs - The new default timeout in milliseconds. * * @example * When I set step timeout to 10000 ms * And I find element by selector "#slow-loading-element" * * @remarks * This step uses Cucumber's `setDefaultTimeout()` function. It applies to all following * steps within the same test run. Use with caution as setting very high timeouts can * hide performance issues. * @category Configuration Steps */ export function When_I_set_step_timeout_to(this: CustomWorld, timeoutMs: number) { setDefaultTimeout(timeoutMs); this.log?.(`⏱️ Default Cucumber step timeout set to ${timeoutMs}ms`); } When("I set step timeout to {int} ms", When_I_set_step_timeout_to); // =================================================================================== // UTILITY ACTIONS: EVENTS // =================================================================================== /** * Triggers a generic DOM event of the given type on the element matching the provided selector. * * ```gherkin * When I trigger {string} event on {string} * ``` * * @param eventType - The type of DOM event to trigger (e.g., "change", "input", "focus"). * @param selector - The CSS selector of the element to trigger the event on. * * @example * When I trigger "change" event on ".my-input" * * @remarks * This step uses Playwright's `locator.evaluate()` to dispatch a new `Event` directly * on the DOM element. It can be useful for simulating browser-level events that * might not be covered by Playwright's high-level actions (like `fill` for `input` events). * @category Event Steps */ export async function When_I_trigger_event_on_selector( this: CustomWorld, eventType: string, selector: string ) { await this.page.locator(selector).evaluate((el: HTMLElement, type: string) => { const event: Event = new Event(type, { bubbles: true, cancelable: true, }); el.dispatchEvent(event); }, eventType); this.log?.(`💥 Triggered "${eventType}" event on element with selector "${selector}".`); } When(/^I trigger "(.*)" event on "([^"]+)"$/, When_I_trigger_event_on_selector); /** * Triggers a generic DOM event of the given type on the previously selected element. * * ```gherkin * When I trigger event {string} * ``` * * @param eventName - The name of the event to dispatch (e.g., "change", "input", "blur"). * * @example * When I find element by selector ".my-input" * And I trigger event "change" * * @remarks * This step requires a preceding step that sets the {@link CustomWorld.element | current element}. * It uses Playwright's `locator.dispatchEvent()` to dispatch the specified event. * @category Event Steps */ export async function When_I_trigger_event(this: CustomWorld, eventName: string) { if (!this.element) throw new Error("No element selected to trigger event on."); await this.element.dispatchEvent(eventName); this.log?.(`💥 Triggered "${eventName}" event on selected element.`); } When("I trigger event {string}", When_I_trigger_event); /** * Removes focus from the previously selected element. * * ```gherkin * When I blur * ``` * * @example * When I find element by selector "input[name='username']" * And I blur * * @remarks * This step requires a preceding step that sets the {@link CustomWorld.element | current element}. * It uses `locator.evaluate()` to call the DOM `blur()` method on the element, * simulating a loss of focus. * @category Event Steps */ export async function When_I_blur(this: CustomWorld) { if (!this.element) throw new Error("No element selected to blur."); await this.element.evaluate((el: HTMLElement) => el.blur()); this.log?.(`👁️‍🗨️ Blurred selected element.`); } When("I blur", When_I_blur); /** * Focuses the previously selected element. * * ```gherkin * When I focus * ``` * * @example * When I find element by selector "input[name='search']" * And I focus * * @remarks * This step requires a preceding step that sets the {@link CustomWorld.element | current element}. * It uses Playwright's `locator.focus()` to bring the element into focus, simulating * a user tabbing to or clicking on the element. * @category Event Steps */ export async function When_I_focus(this: CustomWorld) { if (!this.element) throw new Error("No element selected to focus."); await this.element.focus(); this.log?.(`👁️‍🗨️ Focused selected element.`); } When("I focus", When_I_focus); // =================================================================================== // UTILITY ACTIONS: DEBUGGING / LOGGING // =================================================================================== /** * Logs a message to the test output (stdout/console). * * ```gherkin * When I log {string} * ``` * * @param message - The string message to log. * * @example * When I log "Test scenario started" * * @remarks * This step is useful for injecting debugging or informative messages directly * into the Cucumber test report or console output during test execution. * @category Debugging Steps */ export async function When_I_log(this: CustomWorld, message: string) { this.log(message); } When("I log {string}", When_I_log); /** * Triggers a debugger statement, pausing test execution if a debugger is attached. * * ```gherkin * When I debug * ``` * * @example * When I find element by selector "#problematic-button" * And I debug * When I click current element * * @remarks * This step is extremely useful for interactive debugging. When executed with a debugger * (e.g., VS Code debugger attached to your Node.js process), it will pause execution * at this point, allowing you to inspect the browser state, variables, etc. * @category Debugging Steps */ export async function When_I_debug() { debugger; // This will pause execution if a debugger is attached } When(/^I debug$/, When_I_debug); // =================================================================================== // UTILITY ACTIONS: SCREENSHOT // =================================================================================== /** * Takes a full-page screenshot of the current page and saves it with the given name. * The screenshot will be saved in the `e2e/screenshots/` directory (relative to your project root). * * ```gherkin * When I screenshot {string} * ``` * * @param name - The desired filename for the screenshot (without extension). * * @example * When I screenshot "dashboard-view" * * @remarks * This step creates a PNG image. The `fullPage: true` option ensures that the * entire scrollable height of the page is captured. * @category Screenshot Steps */ export async function When_I_screenshot_named(this: CustomWorld, name: string) { const screenshotPath = `e2e/screenshots/${name}.png`; await this.page.screenshot({ path: screenshotPath, fullPage: true, }); this.log?.(`📸 Saved screenshot to "${screenshotPath}"`); } When(/^I screenshot "(.*)"$/, When_I_screenshot_named); /** * Takes a full-page screenshot of the current page and saves it with a timestamped filename. * The screenshot will be saved in the `screenshots/` directory (relative to your project root). * * ```gherkin * When I screenshot * ``` * * @example * When I screenshot * * @remarks * This step is useful for quick visual debugging or capturing the state of the UI at * various points in the test without needing to manually name each file. * The filename will be in the format `screenshots/screenshot-TIMESTAMP.png`. * @category Screenshot Steps */ export async function When_I_screenshot(this: CustomWorld) { const screenshotPath = `screenshots/screenshot-${Date.now()}.png`; await this.page.screenshot({ path: screenshotPath, fullPage: true }); this.log?.(`📸 Saved screenshot to "${screenshotPath}"`); } When("I screenshot", When_I_screenshot); // =================================================================================== // UTILITY ACTIONS: PAGE NAVIGATION // =================================================================================== /** * Navigates the browser to the given URL or an aliased URL. * If a relative path is provided (starts with `/`), it will be prepended with `process.env.BASE_URL`. * * ```gherkin * When I visit {string} * ``` * * @param urlOrAlias - The URL to visit, or an alias (prefixed with `@`) pointing to a URL. * * @example * When I visit "/dashboard" * When I visit "https://www.example.com" * Given I store "https://my.app.com/profile" as "profilePageUrl" * When I visit "@profilePageUrl" * * @remarks * This step uses Playwright's `page.goto()`. Ensure `BASE_URL` environment variable is set * if you are using relative paths. * @category Page Navigation Steps */ export async function When_I_visit(this: CustomWorld, urlOrAlias: string) { let url = urlOrAlias; if (url.startsWith("@")) { const alias = url.substring(1); url = this.data[alias]; if (!url) throw new Error(`Alias "@${alias}" not found in test data.`); this.log?.(`🔗 Resolved alias "@${alias}" to URL: "${url}"`); } if (url.startsWith("/")) { const baseUrl = process.env.BASE_URL; if (!baseUrl) throw new Error("BASE_URL environment variable is not defined. Cannot visit relative URL."); // Ensure no double slashes if BASE_URL already ends with one url = `${baseUrl.replace(/\/+$/, "")}${url}`; } this.log?.(`🌍 Navigating to: "${url}"`); await this.page.goto(url); } When("I visit {string}", When_I_visit); /** * Reloads the current page. * * ```gherkin * When I reload the page * ``` * * @example * When I reload the page * * @remarks * This step is equivalent to hitting the browser's reload button. * It uses Playwright's `page.reload()`. * @category Page Navigation Steps */ export async function When_I_reload_the_page(this: CustomWorld) { await this.page.reload(); this.log?.(`🔄 Reloaded the current page.`); } When("I reload the page", When_I_reload_the_page); /** * Navigates back in the browser's history. * * ```gherkin * When I go back * ``` * * @example * Given I visit "/page1" * And I visit "/page2" * When I go back * Then I should be on "/page1" * * @remarks * This step is equivalent to hitting the browser's back button. * It uses Playwright's `page.goBack()`. * @category Page Navigation Steps */ export async function When_I_go_back(this: CustomWorld) { await this.page.goBack(); this.log?.(`⬅️ Navigated back in browser history.`); } When("I go back", When_I_go_back); /** * Navigates forward in the browser's history. * * ```gherkin * When I go forward * ``` * * @example * Given I visit "/page1" * And I visit "/page2" * When I go back * When I go forward * Then I should be on "/page2" * * @remarks * This step is equivalent to hitting the browser's forward button. * It uses Playwright's `page.goForward()`. * @category Page Navigation Steps */ export async function When_I_go_forward(this: CustomWorld) { await this.page.goForward(); this.log?.(`➡️ Navigated forward in browser history.`); } When("I go forward", When_I_go_forward); /** * Pauses the test execution in debug mode. * This is useful for inspecting the browser state interactively during test runs. * * ```gherkin * When I pause * ``` * * @example * When I perform an action * And I pause * Then I assert something visually * * @remarks * When running tests in debug mode (e.g., with `npx playwright test --debug`), * this step will open Playwright's inspector, allowing you to step through * actions, inspect elements, and troubleshoot. The test will resume when you * continue from the inspector. * @category Debugging Steps */ export async function When_I_pause(this: CustomWorld) { await this.page.pause(); this.log?.(`⏸️ Test paused. Use Playwright Inspector to continue.`); } When("I pause", When_I_pause); // =================================================================================== // UTILITY ACTIONS: DATE/TIME ALIASING // =================================================================================== const validDateUnits = [ "second", "seconds", "minute", "minutes", "hour", "hours", "day", "days", "week", "weeks", "month", "months", "year", "years", ]; /** * Stores a new date calculated by offsetting an existing aliased date by a given amount and unit. * * ```gherkin * When I store {string} {int} {word} {word} as "{word}" * ``` * * @param baseAlias - The alias of an existing date string in `this.data` (e.g., "today"). * @param amount - The numerical amount to offset by (e.g., 2, 5). * @param unit - The unit of time (e.g., "days", "months", "hours"). * @param direction - Whether to offset "before" or "after" the base date. * @param newAlias - The alias under which to store the newly calculated date. * * @example * Given I store "2024-01-15" as "invoiceDate" * When I store "invoiceDate" 30 days after as "dueDate" * Then the value of alias "dueDate" should be "2024-02-14" * * @remarks * This step uses the `dayjs` library for date manipulation. The `baseAlias` must * point to a valid date string that `dayjs` can parse. The `unit` must be one of: * "second", "minute", "hour", "day", "week", "month", "year" (plural forms also supported). * The new date is stored in `this.data` in "YYYY-MM-DD" format. * @category Data Manipulation Steps */ export async function When_I_store_date_offset( this: CustomWorld, baseAlias: string, amount: number, unit: string, direction: string, // "before" or "after" newAlias: string ) { const baseDateRaw = this.data?.[baseAlias]; if (!baseDateRaw) throw new Error(`Alias "${baseAlias}" not found in test data.`); if (!validDateUnits.includes(unit)) { throw new Error(`Invalid unit "${unit}". Valid units are: ${validDateUnits.join(", ")}.`); } if (!["before", "after"].includes(direction)) { throw new Error(`Invalid direction "${direction}". Must be "before" or "after".`); } const baseDate = dayjs(baseDateRaw); if (!baseDate.isValid()) { throw new Error(`Value for alias "${baseAlias}" ("${baseDateRaw}") is not a valid date.`); } const result = baseDate[direction === "before" ? "subtract" : "add"]( amount, unit as dayjs.ManipulateType ); const formatted = result.format("YYYY-MM-DD"); this.data[newAlias] = formatted; this.log?.( `📅 Stored ${amount} ${unit} ${direction} "${baseAlias}" as "@${newAlias}" = "${formatted}"` ); } When('I store {string} {int} {word} {word} as "{word}"', When_I_store_date_offset); // =================================================================================== // UTILITY ACTIONS: IFRAME // =================================================================================== /** * Switches the current Playwright context to an iframe located by a CSS selector. * The step waits for the iframe's `body` element to be visible before proceeding. * * ```gherkin * When I switch to iframe with selector {string} * ``` * * @param selector - The CSS selector for the iframe element (e.g., "#my-iframe", "iframe[name='chatFrame']"). * * @example * When I switch to iframe with selector "#payment-form-iframe" * And I find element by placeholder text "Card Number" * And I type "1234..." * * @remarks * Once inside an iframe, all subsequent element finding and interaction steps will * target elements within that iframe. To exit the iframe context, use * {@link When_I_exit_iframe | "When I exit iframe"}. * @category IFrame Steps */ export async function When_I_switch_to_iframe_with_selector(this: CustomWorld, selector: string) { const frameLocator = this.page.frameLocator(selector); // Wait for an element inside the iframe to ensure it's loaded and ready await frameLocator.locator("body").waitFor({ state: "visible", timeout: 10000 }); this.frame = frameLocator; // Store the frame locator in CustomWorld context this.log?.(`🪟 Switched to iframe with selector: "${selector}".`); } When("I switch to iframe with selector {string}", When_I_switch_to_iframe_with_selector); /** * Switches the current Playwright context to an iframe located by its title attribute. * * ```gherkin * When I switch to iframe with title {string} * ``` * * @param title - The title of the iframe to switch to. * * @example * When I switch to iframe with title "My Iframe" * And I find element by label text "Card Holder" * * @remarks * This step iterates through all frames on the page to find one whose title matches * (case-insensitively, partially matched with `includes`). Once found, subsequent * element operations will target elements within this iframe. To exit, use * {@link When_I_exit_iframe | "When I exit iframe"}. * @category IFrame Steps */ export async function When_I_switch_to_iframe_with_title(this: CustomWorld, title: string) { // Find the frame by title first to ensure it exists before creating locator const frames = this.page.frames(); const foundFrame = await Promise.race( frames.map(async (f) => { const frameTitle = await f.title(); return frameTitle.includes(title) ? f : null; }) ); if (!foundFrame) throw new Error(`No iframe with title "${title}" found.`); // Playwright recommends using frameLocator for interacting with iframes, // even if found via frame object. this.frame = this.page.frameLocator(`iframe[title*="${title}"]`); this.log?.(`🪟 Switched to iframe titled: "${title}".`); } When("I switch to iframe with title {string}", When_I_switch_to_iframe_with_title); /** * Switches the current Playwright context to an iframe located by a CSS selector, * and then waits for specific text to become visible inside that iframe. * * ```gherkin * When I switch to iframe with selector {string} and wait for text {string} * ``` * * @param selector - The CSS selector for the iframe element. * @param expectedText - The text string to wait for inside the iframe. * * @example * When I switch to iframe with selector "#dynamic-content-iframe" and wait for text "Content Loaded" * * @remarks * This step combines switching into an iframe with a wait condition, which is * useful for dynamic iframe content. The `expectedText` must be present * and visible inside the iframe. To exit the iframe context, use * {@link When_I_exit_iframe | "When I exit iframe"}. * @category IFrame Steps */ export async function When_I_switch_to_iframe_with_selector_and_wait_for_text( this: CustomWorld, selector: string, expectedText: string ) { const frameLocator = this.page.frameLocator(selector); // Wait for the specific text inside the iframe await frameLocator.locator(`text=${expectedText}`).waitFor({ timeout: 10000 }); this.frame = frameLocator; this.log?.( `🪟 Switched to iframe with selector: "${selector}", and waited for text: "${expectedText}".` ); } When( "I switch to iframe with selector {string} and wait for text {string}", When_I_switch_to_iframe_with_selector_and_wait_for_text ); /** * Exits the current iframe context, returning the Playwright context to the main page. * All subsequent element finding and interaction steps will operate on the main page. * * ```gherkin * When I exit iframe * ``` * * @example * When I switch to iframe with selector "#my-iframe" * And I fill "my data" * When I exit iframe * And I click "Main Page Button" * * @remarks * This step is crucial for navigating back to the main document after interacting * with elements inside an iframe. It sets `this.frame` back to `undefined` (or the main page locator). * @category IFrame Steps */ export function When_I_exit_iframe(this: CustomWorld) { this.exitIframe(); // Assuming CustomWorld has an exitIframe method this.log?.(`🪟 Exited iframe context, now interacting with the main page.`); } When("I exit iframe", When_I_exit_iframe); // =================================================================================== // UTILITY ACTIONS: REUSABLE ACTIONS ON STORED ELEMENTS // =================================================================================== // Helper functions (keep these outside the exports or as internal helpers) function toOrdinal(n: number) { const s = ["th", "st", "nd", "rd"]; const v = n % 100; return n + (s[(v - 20) % 10] || s[v] || s[0]); } async function getReadableLabel(el: Locator): Promise<string> { try { const tag = await el.evaluate((el) => el.tagName.toLowerCase()); return tag === "input" ? await el.inputValue() : (await el.innerText()).trim(); } catch { return "(unknown)"; } } // Helper to get a subset of elements (first, last, random, or specific nth for action) async function getElementsSubset( world: CustomWorld, mode: string, count: number ): Promise<Locator[]> { const total = await world.elements?.count(); if (!total || total < 1) throw new Error("No elements stored in 'this.elements' collection."); if (count > total) throw new Error(`Cannot get ${count} elements, only ${total} available.`); switch (mode) { case "first": return Array.from({ length: count }, (_, i) => world.elements!.nth(i)); case "last": return Array.from({ length: count }, (_, i) => world.elements!.nth(total - count + i)); case "random": // Generate unique random indices const indices = new Set<number>(); while (indices.size < count) { indices.add(Math.floor(Math.random() * total)); } return Array.from(indices).map((i) => world.elements!.nth(i)); case "nth": // Used specifically by the "I (action) the Nth element" step if (count < 1) throw new Error(`Invalid Nth element index: ${count}. Must be 1 or greater.`); if (count > total) throw new Error(`Cannot get ${toOrdinal(count)} element, only ${total} available.`); return [world.elements!.nth(count - 1)]; // Return as array for consistent loop below default: throw new Error(`Unsupported subset mode: "${mode}".`); } } // Define the supported actions and their display names for logging type LocatorAction = "click" | "hover" | "check" | "uncheck" | "focus" | "blur" | "fill"; const actionDisplayNames: Record<LocatorAction, string> = { click: "Clicked", hover: "Hovered", check: "Checked", uncheck: "Unchecked", focus: "Focused", blur: "Blurred", fill: "Filled", }; // Define the functions that perform the Playwright actions on a locator const locatorActions: Record<LocatorAction, (el: Locator, table?: DataTable) => Promise<void>> = { click: (el, table) => el.click(parseClickOptions(table)), hover: (el, table) => el.hover(parseHoverOptions(table)), check: (el, table) => el.check(parseCheckOptions(table)), uncheck: (el, table) => el.uncheck(parseUncheckOptions(table)), focus: (el) => el.focus(), blur: (el) => el.evaluate((e: HTMLElement) => e.blur()), fill: (el, table) => el.fill("", parseFillOptions(table)), // This `fill` is generic. If you need to fill with a specific value from the step, // you'll need to pass it as an argument to the actionFn in the step definition. // For now, it's just clearing. }; /** * Performs a specified action (e.g., click, hover, check, uncheck, focus, blur) * on a subset of the previously stored elements (first N, last N, or random N). * * ```gherkin * When I {word} the {word} {int} * ``` * * @param action - The action to perform (e.g., "click", "hover", "check", "uncheck", "focus", "blur", "fill"). * @param mode - The selection mode: "first", "last", or "random". * @param count - The number of elements to apply the action to. * @param table - (Optional) A Cucumber DataTable for action-specific options (e.g., `ClickOptions`). * * @example * Given I find elements by selector ".item-checkbox" * When I check the first 2 * When I hover the last 3 * * @remarks * This step requires that `this.elements` (a Playwright `Locator` that points to multiple * elements) has been populated by a preceding step (e.g., `When I find elements by selector`). * The `action` must be one of the supported actions. The `count` specifies how many of * the matched elements to target. * @category Multi-Element Action Steps */ export async function When_I_perform_action_on_subset_of_elements( this: CustomWorld, action: string, mode: string, count: number, table?: DataTable ) { const elements = await getElementsSubset(this, mode, count); const actionFn = locatorActions[action as LocatorAction]; if (!actionFn) throw new Error(`Unsupported action: "${action}".`); for (const el of elements) { const label = await getReadableLabel(el); await actionFn(el, table); this.log?.(`✅ ${actionDisplayNames[action as LocatorAction] || action} element: "${label}".`); } } When(/^I (\w+) the (first|last|random) (\d+)$/, When_I_perform_action_on_subset_of_elements); /** * Performs a specified action (e.g., click, hover, check, uncheck, focus, blur) * on the Nth element of the previously stored elements collection. * * ```gherkin * When I {word} the {int}(?:st|nd|rd|th) element * ``` * * @param action - The action to perform (e.g., "click", "hover", "check", "uncheck", "focus", "blur", "fill"). * @param nth - The 1-based index of the element to target (e.g., 1 for 1st, 2 for 2nd). * @param table - (Optional) A Cucumber DataTable for action-specific options. * * @example * Given I find elements by selector ".product-card" * When I click the 2nd element * When I fill the 1st element * * @remarks * This step requires that `this.elements` has been populated by a preceding step. * It targets a single element at a specific 1-based ordinal position within that collection. * The `action` must be one of the supported actions. * @category Multi-Element Action Steps */ export async function When_I_perform_action_on_nth_element( this: CustomWorld, action: string, nth: number, table?: DataTable ) { // Use "nth" mode with getElementsSubset to correctly fetch a single element const elements = await getElementsSubset(this, "nth", nth); // This will return an array with one element const targetElement = elements[0]; // Get the single element const actionFn = locatorActions[action as LocatorAction]; if (!actionFn) throw new Error(`Unsupported action: "${action}".`); const label = await getReadableLabel(targetElement); await actionFn(targetElement, table); this.log?.( `✅ ${actionDisplayNames[action as LocatorAction] || action} the ${toOrdinal(nth)} element: "${label}".` ); } When(/^I (\w+) the (\d+)(?:st|nd|rd|th) element$/, When_I_perform_action_on_nth_element); /** * Presses a specific key on the previously selected element. * * ```gherkin * When I press key {string} * ``` * * @param key - The key to press (e.g., "Enter", "Escape", "ArrowDown", "Tab"). * * @example * When I find element by selector "input[name='email']" * And I type "my query" * When I press key "Enter" * * @remarks * This step requires a preceding step that sets the {@link CustomWorld.element | current element}. * It first focuses the element and then simulates a key press. * This is useful for triggering keyboard shortcuts or submitting forms via "Enter". * @category Keyboard Interaction Steps */ export async function When_I_press_key(this: CustomWorld, key: string) { if (!this.element) throw new Error("No element selected to press key on."); await this.element.focus(); await this.page.waitForTimeout(50); // Small buffer to ensure focus await this.element.press(key); this.log?.(`🎹 Pressed key "{${key}}" on selected element.`); } When("I press key {string}", When_I_press_key); // =================================================================================== // UTILITY ACTIONS: VIEWPORT // =================================================================================== /** * Sets the browser viewport to emulate a specific Playwright device profile and orientation. * This will close the current browser context and open a new one with the specified device settings. * * ```gherkin * When I set viewport to {string} * When I set viewport to {string} and {string} * ``` * * @param deviceInput - The name of the Playwright device (e.g., "iPhone 12", "iPad", "Desktop Chrome"). * @param orientation - (Optional) The orientation, either "landscape" or "portrait" (default if not specified). * * @example * When I set viewport to "iPhone 12" * When I set viewport to "iPad" and "landscape" * * @remarks * This step creates a *new* browser context and page, so any previous page state or * setup (like routes, localStorage) will be reset. * The `deviceInput` is normalized to match Playwright's `devices` object keys. * @category Browser Context Steps */ export async function When_I_set_viewport_to_device( this: CustomWorld, deviceInput: string, orientation?: string ) { const normalizedDevice = normalizeDeviceName(deviceInput); if (!normalizedDevice) { throw new Error( `🚫 Unknown device name: "${deviceInput}". Check Playwright 'devices' for valid names.` ); } const baseDevice = devices[normalizedDevice]; if (!baseDevice) { throw new Error(`🚫 Playwright device not found for normalized name: "${normalizedDevice}".`); } const isLandscape = orientation?.toLowerCase() === "landscape"; const deviceSettings: BrowserContextOptions = isLandscape ? (baseDevice as any).landscape // Use Playwright's built-in landscape config if available ? (baseDevice as any).landscape : { // Otherwise, manually adjust for landscape ...baseDevice, isMobile: true, // Assuming mobile devices for landscape adjustment viewport: { width: baseDevice.viewport.height, // Swap width and height for landscape height: baseDevice.viewport.width, }, } : baseDevice; // Use base device settings for portrait or non-mobile // Close current context and page before creating a new one if (this.page) await this.page.close(); if (this.context) await this.context.close(); this.context = await this.browser.newContext(deviceSettings); this.page = await this.context.newPage(); this.log?.(`📱 Set viewport to ${normalizedDevice}${isLandscape ? " in landscape" : ""}.`); } When(/^I set viewport to "([^"]+)"(?: and "([^"]+)")?$/, When_I_set_viewport_to_device); /** * Sets the viewport to the given width and height in pixels. * This will close the current browser context and open a new one with the specified dimensions. * * ```gherkin * When I set viewport to {int}px by {int}px * ``` * * @param width - The desired viewport width in pixels. * @param height - The desired viewport height in pixels. * * @example * When I set viewport to 1280px by 720px * * @remarks * This step creates a *new* browser context and page, so any previous page state or * setup (like routes, localStorage) will be reset. * @category Browser Context Steps */ export async function When_I_set_viewport_to_dimensions( this: CustomWorld, width: number, height: number ) { // Close current context and page before creating a new one if (this.page) await this.page.close(); if (this.context) await this.context.close(); // Recreate new context with the desired viewport this.context = await this.browser.newContext({ viewport: { width, height }, }); this.page = await this.context.newPage(); this.log?.(`🖥️ Set viewport to ${width}px by ${height}px.`); } When("I set viewport to {int}px by {int}px", When_I_set_viewport_to_dimensions); // =================================================================================== // UTILITY ACTIONS: DYNAMIC PLAYWRIGHT CONFIG SETTERS (FOR PAGE-ONLY CONFIG) // =================================================================================== /** * Sets a specific Playwright page configuration property to the given value. * This can be used to dynamically change page-level settings during a test. * * ```gherkin * When I set Playwright config {word} to {string} * ``` * * @param key - The name of the Playwright `Page` property to set (e.g., "userAgent", "defaultTimeout"). * @param value - The string value to set the property to. Note: All values are treated as strings. * * @example * When I set Playwright config "userAgent" to "MyCustomAgent" * * @remarks * This step directly assigns a value to a property on the `this.page` object. * It's important to know which properties are settable and what their expected * types are. Using incorrect keys or values may lead to unexpected behavior or errors. * Not all Playwright page properties are designed to be set this way after page creation. * @category Configuration Steps */ export async function When_I_set_playwright_page_config_key( this: CustomWorld, key: string, value: string ) { // Directly assign property. Using 'as any' to bypass strict type checking, // but be cautious as not all page properties are meant to be set this way dynamically. (this.page as any)[key] = value; this.log?.(`⚙️ Set Playwright page config "${key}" to "${value}".`); } When('I set Playwright config "{word}" to {string}', When_I_set_playwright_page_config_key); /** * Sets multiple Playwright page configuration properties using a data table. * * ```gherkin * When I set Playwright config * | key | value | * | userAgent | MyAgent | * | defaultTimeout | 5000 | * ``` * * @param table - A Cucumber DataTable with two columns: `key` (the property name) * and `value` (the string value to set). * * @example * When I set Playwright config * | key | value | * | userAgent | TestBot | * | defaultTimeout | 10000 | * * @remarks * Similar to the single-key version, this step dynamically assigns values to * properties on the `this.page` object. All values from the data table are * treated as strings. Use with caution, understanding which properties can * be dynamically set. * @category Configuration Steps */ export async function When_I_set_playwright_page_config_from_table( this: CustomWorld, table: DataTable ) { for (const [key, value] of table.rows()) { (this.page as any)[key] = value; // Direct assignment with 'as any' this.log?.(`⚙️ Set Playwright page config "${key}" to "${value}".`); } } When("I set Playwright config", When_I_set_playwright_page_config_from_table);