playwright-cucumber-ts-steps 0.1.7 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/README.md +21 -11
  2. package/lib/actions/clickSteps.d.ts +251 -1
  3. package/lib/actions/clickSteps.js +297 -47
  4. package/lib/actions/cookieSteps.d.ts +18 -1
  5. package/lib/actions/cookieSteps.js +65 -0
  6. package/lib/actions/debugSteps.d.ts +14 -1
  7. package/lib/actions/debugSteps.js +18 -3
  8. package/lib/actions/elementFindSteps.d.ts +668 -1
  9. package/lib/actions/elementFindSteps.js +808 -94
  10. package/lib/actions/fillFormSteps.d.ts +69 -1
  11. package/lib/actions/fillFormSteps.js +178 -71
  12. package/lib/actions/index.d.ts +11 -0
  13. package/lib/actions/index.js +28 -0
  14. package/lib/actions/inputSteps.d.ts +218 -1
  15. package/lib/actions/inputSteps.js +303 -57
  16. package/lib/actions/interceptionSteps.d.ts +169 -1
  17. package/lib/actions/interceptionSteps.js +258 -38
  18. package/lib/actions/miscSteps.d.ts +645 -1
  19. package/lib/actions/miscSteps.js +898 -157
  20. package/lib/actions/mouseSteps.d.ts +143 -1
  21. package/lib/actions/mouseSteps.js +200 -32
  22. package/lib/actions/scrollSteps.d.ts +82 -1
  23. package/lib/actions/scrollSteps.js +116 -16
  24. package/lib/actions/storageSteps.d.ts +174 -1
  25. package/lib/actions/storageSteps.js +253 -33
  26. package/lib/assertions/buttonAndTextVisibilitySteps.d.ts +245 -1
  27. package/lib/assertions/buttonAndTextVisibilitySteps.js +342 -91
  28. package/lib/assertions/cookieSteps.d.ts +75 -1
  29. package/lib/assertions/cookieSteps.js +97 -29
  30. package/lib/assertions/elementSteps.d.ts +264 -1
  31. package/lib/assertions/elementSteps.js +376 -78
  32. package/lib/assertions/formInputSteps.d.ts +248 -1
  33. package/lib/assertions/formInputSteps.js +342 -79
  34. package/lib/assertions/index.d.ts +10 -0
  35. package/lib/assertions/index.js +27 -0
  36. package/lib/assertions/interceptionRequestsSteps.d.ts +353 -1
  37. package/lib/assertions/interceptionRequestsSteps.js +569 -177
  38. package/lib/assertions/locationSteps.d.ts +217 -1
  39. package/lib/assertions/locationSteps.js +287 -64
  40. package/lib/assertions/roleTestIdSteps.d.ts +159 -1
  41. package/lib/assertions/roleTestIdSteps.js +217 -22
  42. package/lib/assertions/semanticSteps.d.ts +176 -1
  43. package/lib/assertions/semanticSteps.js +245 -60
  44. package/lib/assertions/storageSteps.d.ts +149 -1
  45. package/lib/assertions/storageSteps.js +201 -65
  46. package/lib/assertions/visualSteps.d.ts +74 -1
  47. package/lib/assertions/visualSteps.js +178 -45
  48. package/lib/custom_setups/loginHooks.js +19 -2
  49. package/lib/helpers/world.d.ts +3 -0
  50. package/lib/helpers/world.js +11 -5
  51. package/lib/index.d.ts +3 -21
  52. package/lib/index.js +3 -23
  53. package/package.json +9 -2
  54. package/src/actions/clickSteps.ts +364 -142
  55. package/src/actions/cookieSteps.ts +66 -0
  56. package/src/actions/debugSteps.ts +17 -3
  57. package/src/actions/elementFindSteps.ts +822 -117
  58. package/src/actions/fillFormSteps.ts +234 -177
  59. package/src/actions/index.ts +12 -0
  60. package/src/actions/inputSteps.ts +318 -82
  61. package/src/actions/interceptionSteps.ts +295 -57
  62. package/src/actions/miscSteps.ts +984 -254
  63. package/src/actions/mouseSteps.ts +212 -55
  64. package/src/actions/scrollSteps.ts +114 -16
  65. package/src/actions/storageSteps.ts +267 -42
  66. package/src/assertions/buttonAndTextVisibilitySteps.ts +353 -95
  67. package/src/assertions/cookieSteps.ts +115 -36
  68. package/src/assertions/elementSteps.ts +414 -85
  69. package/src/assertions/formInputSteps.ts +375 -108
  70. package/src/assertions/index.ts +11 -0
  71. package/src/assertions/interceptionRequestsSteps.ts +619 -195
  72. package/src/assertions/locationSteps.ts +280 -64
  73. package/src/assertions/roleTestIdSteps.ts +244 -26
  74. package/src/assertions/semanticSteps.ts +257 -69
  75. package/src/assertions/storageSteps.ts +234 -73
  76. package/src/assertions/visualSteps.ts +245 -68
  77. package/src/custom_setups/loginHooks.ts +21 -2
  78. package/src/helpers/world.ts +30 -4
  79. package/src/index.ts +4 -25
@@ -8,158 +8,549 @@ import {
8
8
  parseFillOptions,
9
9
  parseHoverOptions,
10
10
  parseUncheckOptions,
11
- } from "../helpers/utils/optionsUtils";
12
- import { normalizeDeviceName } from "../helpers/utils/resolveUtils";
13
- import { CustomWorld } from "../helpers/world";
11
+ } from "../helpers/utils/optionsUtils"; // Assuming this path is correct
12
+ import { normalizeDeviceName } from "../helpers/utils/resolveUtils"; // Assuming this path is correct
13
+ import { CustomWorld } from "../helpers/world"; // Assuming this path is correct
14
14
 
15
- //
16
- // Timers
17
- //
15
+ // ===================================================================================
16
+ // UTILITY ACTIONS: TIMERS
17
+ // ===================================================================================
18
18
 
19
- When(/^I use fake timers$/, async function (this: any) {
19
+ /**
20
+ * Enables fake timers for the current page, fixing the time at the moment this step is executed.
21
+ * This is useful for testing time-dependent UI components without actual time passing.
22
+ *
23
+ * ```gherkin
24
+ * When I use fake timers
25
+ * ```
26
+ *
27
+ * @example
28
+ * When I use fake timers
29
+ * And I go to "/countdown-page"
30
+ * When I advance timers by 1000 milliseconds
31
+ * Then I should see text "9 seconds remaining"
32
+ *
33
+ * @remarks
34
+ * This step uses Playwright's `page.clock.setFixedTime()` to control the browser's internal
35
+ * clock. All subsequent time-related operations (like `setTimeout`, `setInterval`, `Date.now()`)
36
+ * will operate based on this fixed time. Use {@link When_I_advance_timers_by_milliseconds | "When I advance timers by X milliseconds"}
37
+ * or {@link When_I_advance_timers_by_seconds | "When I advance timers by X seconds"} to progress time.
38
+ * To revert, use {@link When_I_use_real_timers | "When I use real timers"}.
39
+ * @category Timer Steps
40
+ */
41
+ export async function When_I_use_fake_timers(this: CustomWorld) {
20
42
  const initialTime = Date.now();
21
43
  await this.page.clock.setFixedTime(initialTime);
22
- this.fakeTimersActive = true;
23
- });
44
+ this.fakeTimersActive = true; // Assuming CustomWorld has a fakeTimersActive property
45
+ this.log?.(`⏱️ Fake timers enabled, fixed at ${new Date(initialTime).toISOString()}`);
46
+ }
47
+ When(/^I use fake timers$/, When_I_use_fake_timers);
24
48
 
25
- When(/^I use real timers$/, async function (this: any) {
26
- await this.page.clock.restore();
49
+ /**
50
+ * Restores real timers for the current page, releasing control over the browser's internal clock.
51
+ *
52
+ * ```gherkin
53
+ * When I use real timers
54
+ * ```
55
+ *
56
+ * @example
57
+ * When I use fake timers
58
+ * When I advance timers by 10 seconds
59
+ * When I use real timers
60
+ *
61
+ * @remarks
62
+ * This step uses Playwright's `page.clock.useRealTimers()`. After this step, `setTimeout`, `setInterval`,
63
+ * and other time-related functions will behave normally, using the system's real time.
64
+ * @category Timer Steps
65
+ */
66
+ export async function When_I_use_real_timers(this: CustomWorld) {
67
+ // FIX: Use 'as any' to tell TypeScript that this property exists at runtime.
68
+ // This is a common workaround for experimental/plugin-like APIs that aren't fully typed.
69
+ await (this.page.clock as any).useRealTimers();
27
70
  this.fakeTimersActive = false;
28
- });
29
-
30
- When(/^I advance timers by (\d+) milliseconds$/, async function (this: any, ms: string) {
71
+ this.log?.(`⏱️ Real timers restored.`);
72
+ }
73
+ When(/^I use real timers$/, When_I_use_real_timers);
74
+ export async function When_I_advance_timers_by_milliseconds(this: CustomWorld, ms: number) {
31
75
  if (this.fakeTimersActive) {
32
- await this.page.clock.tick(parseInt(ms));
76
+ // FIX: Use 'as any' for tick()
77
+ await (this.page.clock as any).tick(ms);
78
+ this.log?.(`⏱️ Advanced fake timers by ${ms} milliseconds.`);
33
79
  } else {
34
- console.warn("Real timers active. Consider using setTimeout.");
80
+ this.log?.("⚠️ Real timers are active. `When I advance timers by...` has no effect.");
35
81
  }
36
- });
82
+ }
83
+ When(/^I advance timers by (\d+) milliseconds$/, When_I_advance_timers_by_milliseconds); // This line remains unchanged
37
84
 
38
- When(/^I advance timers by (\d+) seconds$/, async function (this: any, seconds: string) {
39
- const ms = parseInt(seconds) * 1000;
85
+ /**
86
+ * Advances fake timers by the given number of seconds. Requires fake timers to be enabled.
87
+ *
88
+ * ```gherkin
89
+ * When I advance timers by {int} seconds
90
+ * ```
91
+ *
92
+ * @param seconds - The number of seconds to advance the fake clock by.
93
+ *
94
+ * @example
95
+ * When I use fake timers
96
+ * When I advance timers by 2 seconds
97
+ *
98
+ * @remarks
99
+ * This step converts seconds to milliseconds and uses Playwright's `page.clock.tick()`.
100
+ * It will only have an effect if {@link When_I_use_fake_timers | "When I use fake timers"}
101
+ * has been called previously. If real timers are active, a warning will be logged.
102
+ * @category Timer Steps
103
+ */
104
+ export async function When_I_advance_timers_by_seconds(this: CustomWorld, seconds: number) {
105
+ const ms = seconds * 1000;
40
106
  if (this.fakeTimersActive) {
41
- await this.page.clock.tick(ms);
107
+ // FIX: Use 'as any' for tick()
108
+ await (this.page.clock as any).tick(ms);
109
+ this.log?.(`⏱️ Advanced fake timers by ${seconds} seconds.`);
42
110
  } else {
43
- console.warn("Real timers active. Consider using setTimeout.");
111
+ this.log?.("⚠️ Real timers are active. `When I advance timers by...` has no effect.");
44
112
  }
45
- });
46
-
47
- When(/^I wait (\d+) second[s]?$/, async function (seconds: number) {
113
+ }
114
+ // This line remains unchanged, as it's just the Cucumber step definition linking to the function
115
+ // When(/^I advance timers by (\d+) seconds$/, When_I_advance_timers_by_seconds);
116
+ /**
117
+ * Waits for the given number of seconds using `setTimeout`. This is a real-time wait.
118
+ *
119
+ * ```gherkin
120
+ * When I wait {int} second[s]
121
+ * ```
122
+ *
123
+ * @param seconds - The number of seconds to wait.
124
+ *
125
+ * @example
126
+ * When I wait 3 seconds
127
+ *
128
+ * @remarks
129
+ * This step pauses test execution for the specified duration using Node.js `setTimeout`.
130
+ * It's generally preferred to use explicit waits for element conditions (e.g., `toBeVisible`)
131
+ * over arbitrary waits, but this can be useful for debugging or waiting for external factors.
132
+ * @category General Action Steps
133
+ */
134
+ export async function When_I_wait_seconds(seconds: number) {
48
135
  await new Promise((resolve) => setTimeout(resolve, seconds * 1000));
49
- });
136
+ }
137
+ When(/^I wait (\d+) second[s]?$/, When_I_wait_seconds);
50
138
 
51
- When(/^I wait (\d+) millisecond[s]?$/, async function (this: CustomWorld, ms: number) {
139
+ /**
140
+ * Waits for the given number of milliseconds using `setTimeout`. This is a real-time wait.
141
+ *
142
+ * ```gherkin
143
+ * When I wait {int} millisecond[s]
144
+ * ```
145
+ *
146
+ * @param ms - The number of milliseconds to wait.
147
+ *
148
+ * @example
149
+ * When I wait 500 milliseconds
150
+ *
151
+ * @remarks
152
+ * This step pauses test execution for the specified duration using Node.js `setTimeout`.
153
+ * It's generally preferred to use explicit waits for element conditions (e.g., `toBeVisible`)
154
+ * over arbitrary waits, but this can be useful for debugging or waiting for external factors.
155
+ * @category General Action Steps
156
+ */
157
+ export async function When_I_wait_milliseconds(this: CustomWorld, ms: number) {
52
158
  await new Promise((res) => setTimeout(res, ms));
53
- });
54
- When("I set step timeout to {int} ms", function (timeoutMs: number) {
159
+ }
160
+ When(/^I wait (\d+) millisecond[s]?$/, When_I_wait_milliseconds);
161
+
162
+ /**
163
+ * Sets the default step timeout for all subsequent Cucumber steps.
164
+ * This can override the global timeout set in `cucumber.js` configuration.
165
+ *
166
+ * ```gherkin
167
+ * When I set step timeout to {int} ms
168
+ * ```
169
+ *
170
+ * @param timeoutMs - The new default timeout in milliseconds.
171
+ *
172
+ * @example
173
+ * When I set step timeout to 10000 ms
174
+ * And I find element by selector "#slow-loading-element"
175
+ *
176
+ * @remarks
177
+ * This step uses Cucumber's `setDefaultTimeout()` function. It applies to all following
178
+ * steps within the same test run. Use with caution as setting very high timeouts can
179
+ * hide performance issues.
180
+ * @category Configuration Steps
181
+ */
182
+ export function When_I_set_step_timeout_to(this: CustomWorld, timeoutMs: number) {
55
183
  setDefaultTimeout(timeoutMs);
56
- this.log?.(`⏱️ Timeout set to ${timeoutMs}ms`);
57
- });
58
- //
59
- // Events
60
- //
184
+ this.log?.(`⏱️ Default Cucumber step timeout set to ${timeoutMs}ms`);
185
+ }
186
+ When("I set step timeout to {int} ms", When_I_set_step_timeout_to);
61
187
 
62
- When(
63
- /^I trigger "(.*)" event on "([^"]+)"$/,
64
- async function (this: any, eventType: string, selector: string) {
65
- await this.page.locator(selector).evaluate((el: HTMLElement, type: string) => {
66
- const event: Event = new Event(type, {
67
- bubbles: true,
68
- cancelable: true,
69
- });
70
- el.dispatchEvent(event);
71
- }, eventType);
72
- }
73
- );
188
+ // ===================================================================================
189
+ // UTILITY ACTIONS: EVENTS
190
+ // ===================================================================================
191
+
192
+ /**
193
+ * Triggers a generic DOM event of the given type on the element matching the provided selector.
194
+ *
195
+ * ```gherkin
196
+ * When I trigger {string} event on {string}
197
+ * ```
198
+ *
199
+ * @param eventType - The type of DOM event to trigger (e.g., "change", "input", "focus").
200
+ * @param selector - The CSS selector of the element to trigger the event on.
201
+ *
202
+ * @example
203
+ * When I trigger "change" event on ".my-input"
204
+ *
205
+ * @remarks
206
+ * This step uses Playwright's `locator.evaluate()` to dispatch a new `Event` directly
207
+ * on the DOM element. It can be useful for simulating browser-level events that
208
+ * might not be covered by Playwright's high-level actions (like `fill` for `input` events).
209
+ * @category Event Steps
210
+ */
211
+ export async function When_I_trigger_event_on_selector(
212
+ this: CustomWorld,
213
+ eventType: string,
214
+ selector: string
215
+ ) {
216
+ await this.page.locator(selector).evaluate((el: HTMLElement, type: string) => {
217
+ const event: Event = new Event(type, {
218
+ bubbles: true,
219
+ cancelable: true,
220
+ });
221
+ el.dispatchEvent(event);
222
+ }, eventType);
223
+ this.log?.(`💥 Triggered "${eventType}" event on element with selector "${selector}".`);
224
+ }
225
+ When(/^I trigger "(.*)" event on "([^"]+)"$/, When_I_trigger_event_on_selector);
74
226
 
75
- When("I trigger event {string}", async function (this: CustomWorld, eventName: string) {
76
- if (!this.element) throw new Error("No element selected");
227
+ /**
228
+ * Triggers a generic DOM event of the given type on the previously selected element.
229
+ *
230
+ * ```gherkin
231
+ * When I trigger event {string}
232
+ * ```
233
+ *
234
+ * @param eventName - The name of the event to dispatch (e.g., "change", "input", "blur").
235
+ *
236
+ * @example
237
+ * When I find element by selector ".my-input"
238
+ * And I trigger event "change"
239
+ *
240
+ * @remarks
241
+ * This step requires a preceding step that sets the {@link CustomWorld.element | current element}.
242
+ * It uses Playwright's `locator.dispatchEvent()` to dispatch the specified event.
243
+ * @category Event Steps
244
+ */
245
+ export async function When_I_trigger_event(this: CustomWorld, eventName: string) {
246
+ if (!this.element) throw new Error("No element selected to trigger event on.");
77
247
  await this.element.dispatchEvent(eventName);
78
- });
248
+ this.log?.(`💥 Triggered "${eventName}" event on selected element.`);
249
+ }
250
+ When("I trigger event {string}", When_I_trigger_event);
79
251
 
80
- When("I blur", async function (this: CustomWorld) {
81
- if (!this.element) throw new Error("No element selected");
252
+ /**
253
+ * Removes focus from the previously selected element.
254
+ *
255
+ * ```gherkin
256
+ * When I blur
257
+ * ```
258
+ *
259
+ * @example
260
+ * When I find element by selector "input[name='username']"
261
+ * And I blur
262
+ *
263
+ * @remarks
264
+ * This step requires a preceding step that sets the {@link CustomWorld.element | current element}.
265
+ * It uses `locator.evaluate()` to call the DOM `blur()` method on the element,
266
+ * simulating a loss of focus.
267
+ * @category Event Steps
268
+ */
269
+ export async function When_I_blur(this: CustomWorld) {
270
+ if (!this.element) throw new Error("No element selected to blur.");
82
271
  await this.element.evaluate((el: HTMLElement) => el.blur());
83
- });
272
+ this.log?.(`👁️‍🗨️ Blurred selected element.`);
273
+ }
274
+ When("I blur", When_I_blur);
84
275
 
85
- When("I focus", async function (this: CustomWorld) {
86
- if (!this.element) throw new Error("No element selected");
276
+ /**
277
+ * Focuses the previously selected element.
278
+ *
279
+ * ```gherkin
280
+ * When I focus
281
+ * ```
282
+ *
283
+ * @example
284
+ * When I find element by selector "input[name='search']"
285
+ * And I focus
286
+ *
287
+ * @remarks
288
+ * This step requires a preceding step that sets the {@link CustomWorld.element | current element}.
289
+ * It uses Playwright's `locator.focus()` to bring the element into focus, simulating
290
+ * a user tabbing to or clicking on the element.
291
+ * @category Event Steps
292
+ */
293
+ export async function When_I_focus(this: CustomWorld) {
294
+ if (!this.element) throw new Error("No element selected to focus.");
87
295
  await this.element.focus();
88
- });
296
+ this.log?.(`👁️‍🗨️ Focused selected element.`);
297
+ }
298
+ When("I focus", When_I_focus);
89
299
 
90
- //
91
- // Debugging / Logging
92
- //
300
+ // ===================================================================================
301
+ // UTILITY ACTIONS: DEBUGGING / LOGGING
302
+ // ===================================================================================
93
303
 
94
- When("I log {string}", async function (this: CustomWorld, message: string) {
304
+ /**
305
+ * Logs a message to the test output (stdout/console).
306
+ *
307
+ * ```gherkin
308
+ * When I log {string}
309
+ * ```
310
+ *
311
+ * @param message - The string message to log.
312
+ *
313
+ * @example
314
+ * When I log "Test scenario started"
315
+ *
316
+ * @remarks
317
+ * This step is useful for injecting debugging or informative messages directly
318
+ * into the Cucumber test report or console output during test execution.
319
+ * @category Debugging Steps
320
+ */
321
+ export async function When_I_log(this: CustomWorld, message: string) {
95
322
  this.log(message);
96
- });
323
+ }
324
+ When("I log {string}", When_I_log);
97
325
 
98
- When(/^I debug$/, async function () {
99
- debugger;
100
- });
326
+ /**
327
+ * Triggers a debugger statement, pausing test execution if a debugger is attached.
328
+ *
329
+ * ```gherkin
330
+ * When I debug
331
+ * ```
332
+ *
333
+ * @example
334
+ * When I find element by selector "#problematic-button"
335
+ * And I debug
336
+ * When I click current element
337
+ *
338
+ * @remarks
339
+ * This step is extremely useful for interactive debugging. When executed with a debugger
340
+ * (e.g., VS Code debugger attached to your Node.js process), it will pause execution
341
+ * at this point, allowing you to inspect the browser state, variables, etc.
342
+ * @category Debugging Steps
343
+ */
344
+ export async function When_I_debug() {
345
+ debugger; // This will pause execution if a debugger is attached
346
+ }
347
+ When(/^I debug$/, When_I_debug);
101
348
 
102
- //
103
- // Screenshot
104
- //
349
+ // ===================================================================================
350
+ // UTILITY ACTIONS: SCREENSHOT
351
+ // ===================================================================================
105
352
 
106
- When(/^I screenshot "(.*)"$/, async function (this: any, name: string) {
353
+ /**
354
+ * Takes a full-page screenshot of the current page and saves it with the given name.
355
+ * The screenshot will be saved in the `e2e/screenshots/` directory (relative to your project root).
356
+ *
357
+ * ```gherkin
358
+ * When I screenshot {string}
359
+ * ```
360
+ *
361
+ * @param name - The desired filename for the screenshot (without extension).
362
+ *
363
+ * @example
364
+ * When I screenshot "dashboard-view"
365
+ *
366
+ * @remarks
367
+ * This step creates a PNG image. The `fullPage: true` option ensures that the
368
+ * entire scrollable height of the page is captured.
369
+ * @category Screenshot Steps
370
+ */
371
+ export async function When_I_screenshot_named(this: CustomWorld, name: string) {
372
+ const screenshotPath = `e2e/screenshots/${name}.png`;
107
373
  await this.page.screenshot({
108
- path: `e2e/screenshots/${name}.png`,
374
+ path: screenshotPath,
109
375
  fullPage: true,
110
376
  });
111
- });
377
+ this.log?.(`📸 Saved screenshot to "${screenshotPath}"`);
378
+ }
379
+ When(/^I screenshot "(.*)"$/, When_I_screenshot_named);
112
380
 
113
- When("I screenshot", async function (this: CustomWorld) {
114
- const path = `screenshots/screenshot-${Date.now()}.png`;
115
- await this.page.screenshot({ path, fullPage: true });
116
- this.log?.(`Saved screenshot to ${path}`);
117
- });
381
+ /**
382
+ * Takes a full-page screenshot of the current page and saves it with a timestamped filename.
383
+ * The screenshot will be saved in the `screenshots/` directory (relative to your project root).
384
+ *
385
+ * ```gherkin
386
+ * When I screenshot
387
+ * ```
388
+ *
389
+ * @example
390
+ * When I screenshot
391
+ *
392
+ * @remarks
393
+ * This step is useful for quick visual debugging or capturing the state of the UI at
394
+ * various points in the test without needing to manually name each file.
395
+ * The filename will be in the format `screenshots/screenshot-TIMESTAMP.png`.
396
+ * @category Screenshot Steps
397
+ */
398
+ export async function When_I_screenshot(this: CustomWorld) {
399
+ const screenshotPath = `screenshots/screenshot-${Date.now()}.png`;
400
+ await this.page.screenshot({ path: screenshotPath, fullPage: true });
401
+ this.log?.(`📸 Saved screenshot to "${screenshotPath}"`);
402
+ }
403
+ When("I screenshot", When_I_screenshot);
118
404
 
119
- //
120
- // Page Navigation
121
- //
405
+ // ===================================================================================
406
+ // UTILITY ACTIONS: PAGE NAVIGATION
407
+ // ===================================================================================
122
408
 
123
- When("I visit {string}", async function (this: CustomWorld, urlOrAlias: string) {
409
+ /**
410
+ * Navigates the browser to the given URL or an aliased URL.
411
+ * If a relative path is provided (starts with `/`), it will be prepended with `process.env.BASE_URL`.
412
+ *
413
+ * ```gherkin
414
+ * When I visit {string}
415
+ * ```
416
+ *
417
+ * @param urlOrAlias - The URL to visit, or an alias (prefixed with `@`) pointing to a URL.
418
+ *
419
+ * @example
420
+ * When I visit "/dashboard"
421
+ * When I visit "https://www.example.com"
422
+ * Given I store "https://my.app.com/profile" as "profilePageUrl"
423
+ * When I visit "@profilePageUrl"
424
+ *
425
+ * @remarks
426
+ * This step uses Playwright's `page.goto()`. Ensure `BASE_URL` environment variable is set
427
+ * if you are using relative paths.
428
+ * @category Page Navigation Steps
429
+ */
430
+ export async function When_I_visit(this: CustomWorld, urlOrAlias: string) {
124
431
  let url = urlOrAlias;
125
432
 
126
433
  if (url.startsWith("@")) {
127
434
  const alias = url.substring(1);
128
435
  url = this.data[alias];
129
- if (!url) throw new Error(`Alias @${alias} not found`);
436
+ if (!url) throw new Error(`Alias "@${alias}" not found in test data.`);
437
+ this.log?.(`🔗 Resolved alias "@${alias}" to URL: "${url}"`);
130
438
  }
131
439
 
132
440
  if (url.startsWith("/")) {
133
441
  const baseUrl = process.env.BASE_URL;
134
- if (!baseUrl) throw new Error("BASE_URL not defined");
442
+ if (!baseUrl)
443
+ throw new Error("BASE_URL environment variable is not defined. Cannot visit relative URL.");
444
+ // Ensure no double slashes if BASE_URL already ends with one
135
445
  url = `${baseUrl.replace(/\/+$/, "")}${url}`;
136
446
  }
137
447
 
138
- this.log?.(`Visiting: ${url}`);
448
+ this.log?.(`🌍 Navigating to: "${url}"`);
139
449
  await this.page.goto(url);
140
- });
450
+ }
451
+ When("I visit {string}", When_I_visit);
141
452
 
142
- When("I reload the page", async function (this: CustomWorld) {
453
+ /**
454
+ * Reloads the current page.
455
+ *
456
+ * ```gherkin
457
+ * When I reload the page
458
+ * ```
459
+ *
460
+ * @example
461
+ * When I reload the page
462
+ *
463
+ * @remarks
464
+ * This step is equivalent to hitting the browser's reload button.
465
+ * It uses Playwright's `page.reload()`.
466
+ * @category Page Navigation Steps
467
+ */
468
+ export async function When_I_reload_the_page(this: CustomWorld) {
143
469
  await this.page.reload();
144
- });
470
+ this.log?.(`🔄 Reloaded the current page.`);
471
+ }
472
+ When("I reload the page", When_I_reload_the_page);
145
473
 
146
- When("I go back", async function (this: CustomWorld) {
474
+ /**
475
+ * Navigates back in the browser's history.
476
+ *
477
+ * ```gherkin
478
+ * When I go back
479
+ * ```
480
+ *
481
+ * @example
482
+ * Given I visit "/page1"
483
+ * And I visit "/page2"
484
+ * When I go back
485
+ * Then I should be on "/page1"
486
+ *
487
+ * @remarks
488
+ * This step is equivalent to hitting the browser's back button.
489
+ * It uses Playwright's `page.goBack()`.
490
+ * @category Page Navigation Steps
491
+ */
492
+ export async function When_I_go_back(this: CustomWorld) {
147
493
  await this.page.goBack();
148
- });
494
+ this.log?.(`⬅️ Navigated back in browser history.`);
495
+ }
496
+ When("I go back", When_I_go_back);
149
497
 
150
- When("I go forward", async function (this: CustomWorld) {
498
+ /**
499
+ * Navigates forward in the browser's history.
500
+ *
501
+ * ```gherkin
502
+ * When I go forward
503
+ * ```
504
+ *
505
+ * @example
506
+ * Given I visit "/page1"
507
+ * And I visit "/page2"
508
+ * When I go back
509
+ * When I go forward
510
+ * Then I should be on "/page2"
511
+ *
512
+ * @remarks
513
+ * This step is equivalent to hitting the browser's forward button.
514
+ * It uses Playwright's `page.goForward()`.
515
+ * @category Page Navigation Steps
516
+ */
517
+ export async function When_I_go_forward(this: CustomWorld) {
151
518
  await this.page.goForward();
152
- });
519
+ this.log?.(`➡️ Navigated forward in browser history.`);
520
+ }
521
+ When("I go forward", When_I_go_forward);
153
522
 
154
- When("I pause", async function () {
523
+ /**
524
+ * Pauses the test execution in debug mode.
525
+ * This is useful for inspecting the browser state interactively during test runs.
526
+ *
527
+ * ```gherkin
528
+ * When I pause
529
+ * ```
530
+ *
531
+ * @example
532
+ * When I perform an action
533
+ * And I pause
534
+ * Then I assert something visually
535
+ *
536
+ * @remarks
537
+ * When running tests in debug mode (e.g., with `npx playwright test --debug`),
538
+ * this step will open Playwright's inspector, allowing you to step through
539
+ * actions, inspect elements, and troubleshoot. The test will resume when you
540
+ * continue from the inspector.
541
+ * @category Debugging Steps
542
+ */
543
+ export async function When_I_pause(this: CustomWorld) {
155
544
  await this.page.pause();
156
- });
545
+ this.log?.(`⏸️ Test paused. Use Playwright Inspector to continue.`);
546
+ }
547
+ When("I pause", When_I_pause);
157
548
 
158
- //
159
- // Date/Time Aliasing
160
- //
549
+ // ===================================================================================
550
+ // UTILITY ACTIONS: DATE/TIME ALIASING
551
+ // ===================================================================================
161
552
 
162
- const validUnits = [
553
+ const validDateUnits = [
163
554
  "second",
164
555
  "seconds",
165
556
  "minute",
@@ -176,75 +567,208 @@ const validUnits = [
176
567
  "years",
177
568
  ];
178
569
 
179
- When(
180
- 'I store {string} {int} {word} {word} as "{word}"',
181
- async function (
182
- this: CustomWorld,
183
- baseAlias: string,
184
- amount: number,
185
- unit: string,
186
- direction: string,
187
- newAlias: string
188
- ) {
189
- const baseDateRaw = this.data?.[baseAlias];
190
- if (!baseDateRaw) throw new Error(`Alias "${baseAlias}" not found`);
191
- if (!validUnits.includes(unit)) throw new Error(`Invalid unit "${unit}"`);
192
-
193
- const baseDate = dayjs(baseDateRaw);
194
- if (!baseDate.isValid()) throw new Error(`Invalid date for "${baseAlias}"`);
195
-
196
- const result = baseDate[direction === "before" ? "subtract" : "add"](
197
- amount,
198
- unit as dayjs.ManipulateType
199
- );
200
- const formatted = result.format("YYYY-MM-DD");
570
+ /**
571
+ * Stores a new date calculated by offsetting an existing aliased date by a given amount and unit.
572
+ *
573
+ * ```gherkin
574
+ * When I store {string} {int} {word} {word} as "{word}"
575
+ * ```
576
+ *
577
+ * @param baseAlias - The alias of an existing date string in `this.data` (e.g., "today").
578
+ * @param amount - The numerical amount to offset by (e.g., 2, 5).
579
+ * @param unit - The unit of time (e.g., "days", "months", "hours").
580
+ * @param direction - Whether to offset "before" or "after" the base date.
581
+ * @param newAlias - The alias under which to store the newly calculated date.
582
+ *
583
+ * @example
584
+ * Given I store "2024-01-15" as "invoiceDate"
585
+ * When I store "invoiceDate" 30 days after as "dueDate"
586
+ * Then the value of alias "dueDate" should be "2024-02-14"
587
+ *
588
+ * @remarks
589
+ * This step uses the `dayjs` library for date manipulation. The `baseAlias` must
590
+ * point to a valid date string that `dayjs` can parse. The `unit` must be one of:
591
+ * "second", "minute", "hour", "day", "week", "month", "year" (plural forms also supported).
592
+ * The new date is stored in `this.data` in "YYYY-MM-DD" format.
593
+ * @category Data Manipulation Steps
594
+ */
595
+ export async function When_I_store_date_offset(
596
+ this: CustomWorld,
597
+ baseAlias: string,
598
+ amount: number,
599
+ unit: string,
600
+ direction: string, // "before" or "after"
601
+ newAlias: string
602
+ ) {
603
+ const baseDateRaw = this.data?.[baseAlias];
604
+ if (!baseDateRaw) throw new Error(`Alias "${baseAlias}" not found in test data.`);
605
+ if (!validDateUnits.includes(unit)) {
606
+ throw new Error(`Invalid unit "${unit}". Valid units are: ${validDateUnits.join(", ")}.`);
607
+ }
608
+ if (!["before", "after"].includes(direction)) {
609
+ throw new Error(`Invalid direction "${direction}". Must be "before" or "after".`);
610
+ }
201
611
 
202
- this.data[newAlias] = formatted;
203
- this.log?.(
204
- `📅 Stored ${amount} ${unit} ${direction} "${baseAlias}" as "@${newAlias}" = ${formatted}`
205
- );
612
+ const baseDate = dayjs(baseDateRaw);
613
+ if (!baseDate.isValid()) {
614
+ throw new Error(`Value for alias "${baseAlias}" ("${baseDateRaw}") is not a valid date.`);
206
615
  }
207
- );
208
616
 
209
- //
210
- // IFrame
211
- //
617
+ const result = baseDate[direction === "before" ? "subtract" : "add"](
618
+ amount,
619
+ unit as dayjs.ManipulateType
620
+ );
621
+ const formatted = result.format("YYYY-MM-DD");
212
622
 
213
- When(
214
- "I switch to iframe with selector {string}",
215
- async function (this: CustomWorld, selector: string) {
216
- const frameLocator = this.page.frameLocator(selector);
217
- await frameLocator.locator("body").waitFor({ state: "visible", timeout: 10000 });
218
- this.frame = frameLocator;
219
- this.log?.(`🪟 Switched to iframe: ${selector}`);
220
- }
221
- );
623
+ this.data[newAlias] = formatted;
624
+ this.log?.(
625
+ `📅 Stored ${amount} ${unit} ${direction} "${baseAlias}" as "@${newAlias}" = "${formatted}"`
626
+ );
627
+ }
628
+ When('I store {string} {int} {word} {word} as "{word}"', When_I_store_date_offset);
629
+
630
+ // ===================================================================================
631
+ // UTILITY ACTIONS: IFRAME
632
+ // ===================================================================================
222
633
 
223
- When("I switch to iframe with title {string}", async function (this: CustomWorld, title: string) {
634
+ /**
635
+ * Switches the current Playwright context to an iframe located by a CSS selector.
636
+ * The step waits for the iframe's `body` element to be visible before proceeding.
637
+ *
638
+ * ```gherkin
639
+ * When I switch to iframe with selector {string}
640
+ * ```
641
+ *
642
+ * @param selector - The CSS selector for the iframe element (e.g., "#my-iframe", "iframe[name='chatFrame']").
643
+ *
644
+ * @example
645
+ * When I switch to iframe with selector "#payment-form-iframe"
646
+ * And I find element by placeholder text "Card Number"
647
+ * And I type "1234..."
648
+ *
649
+ * @remarks
650
+ * Once inside an iframe, all subsequent element finding and interaction steps will
651
+ * target elements within that iframe. To exit the iframe context, use
652
+ * {@link When_I_exit_iframe | "When I exit iframe"}.
653
+ * @category IFrame Steps
654
+ */
655
+ export async function When_I_switch_to_iframe_with_selector(this: CustomWorld, selector: string) {
656
+ const frameLocator = this.page.frameLocator(selector);
657
+ // Wait for an element inside the iframe to ensure it's loaded and ready
658
+ await frameLocator.locator("body").waitFor({ state: "visible", timeout: 10000 });
659
+ this.frame = frameLocator; // Store the frame locator in CustomWorld context
660
+ this.log?.(`🪟 Switched to iframe with selector: "${selector}".`);
661
+ }
662
+ When("I switch to iframe with selector {string}", When_I_switch_to_iframe_with_selector);
663
+
664
+ /**
665
+ * Switches the current Playwright context to an iframe located by its title attribute.
666
+ *
667
+ * ```gherkin
668
+ * When I switch to iframe with title {string}
669
+ * ```
670
+ *
671
+ * @param title - The title of the iframe to switch to.
672
+ *
673
+ * @example
674
+ * When I switch to iframe with title "My Iframe"
675
+ * And I find element by label text "Card Holder"
676
+ *
677
+ * @remarks
678
+ * This step iterates through all frames on the page to find one whose title matches
679
+ * (case-insensitively, partially matched with `includes`). Once found, subsequent
680
+ * element operations will target elements within this iframe. To exit, use
681
+ * {@link When_I_exit_iframe | "When I exit iframe"}.
682
+ * @category IFrame Steps
683
+ */
684
+ export async function When_I_switch_to_iframe_with_title(this: CustomWorld, title: string) {
685
+ // Find the frame by title first to ensure it exists before creating locator
224
686
  const frames = this.page.frames();
225
- const match = frames.find((f) => f.title().then((t) => t.includes(title)));
226
- if (!match) throw new Error(`No iframe with title "${title}"`);
687
+ const foundFrame = await Promise.race(
688
+ frames.map(async (f) => {
689
+ const frameTitle = await f.title();
690
+ return frameTitle.includes(title) ? f : null;
691
+ })
692
+ );
693
+
694
+ if (!foundFrame) throw new Error(`No iframe with title "${title}" found.`);
695
+
696
+ // Playwright recommends using frameLocator for interacting with iframes,
697
+ // even if found via frame object.
227
698
  this.frame = this.page.frameLocator(`iframe[title*="${title}"]`);
228
- this.log?.(`🪟 Switched to iframe titled: ${title}`);
229
- });
699
+ this.log?.(`🪟 Switched to iframe titled: "${title}".`);
700
+ }
701
+ When("I switch to iframe with title {string}", When_I_switch_to_iframe_with_title);
702
+
703
+ /**
704
+ * Switches the current Playwright context to an iframe located by a CSS selector,
705
+ * and then waits for specific text to become visible inside that iframe.
706
+ *
707
+ * ```gherkin
708
+ * When I switch to iframe with selector {string} and wait for text {string}
709
+ * ```
710
+ *
711
+ * @param selector - The CSS selector for the iframe element.
712
+ * @param expectedText - The text string to wait for inside the iframe.
713
+ *
714
+ * @example
715
+ * When I switch to iframe with selector "#dynamic-content-iframe" and wait for text "Content Loaded"
716
+ *
717
+ * @remarks
718
+ * This step combines switching into an iframe with a wait condition, which is
719
+ * useful for dynamic iframe content. The `expectedText` must be present
720
+ * and visible inside the iframe. To exit the iframe context, use
721
+ * {@link When_I_exit_iframe | "When I exit iframe"}.
722
+ * @category IFrame Steps
723
+ */
724
+ export async function When_I_switch_to_iframe_with_selector_and_wait_for_text(
725
+ this: CustomWorld,
726
+ selector: string,
727
+ expectedText: string
728
+ ) {
729
+ const frameLocator = this.page.frameLocator(selector);
730
+ // Wait for the specific text inside the iframe
731
+ await frameLocator.locator(`text=${expectedText}`).waitFor({ timeout: 10000 });
732
+ this.frame = frameLocator;
733
+ this.log?.(
734
+ `🪟 Switched to iframe with selector: "${selector}", and waited for text: "${expectedText}".`
735
+ );
736
+ }
230
737
  When(
231
738
  "I switch to iframe with selector {string} and wait for text {string}",
232
- async function (this: CustomWorld, selector: string, expected: string) {
233
- const frameLocator = this.page.frameLocator(selector);
234
- await frameLocator.locator(`text=${expected}`).waitFor({ timeout: 10000 });
235
- this.frame = frameLocator;
236
- this.log?.(`🪟 Switched to iframe: ${selector}, waited for "${expected}"`);
237
- }
739
+ When_I_switch_to_iframe_with_selector_and_wait_for_text
238
740
  );
239
741
 
240
- When("I exit iframe", function (this: CustomWorld) {
241
- this.exitIframe();
242
- });
742
+ /**
743
+ * Exits the current iframe context, returning the Playwright context to the main page.
744
+ * All subsequent element finding and interaction steps will operate on the main page.
745
+ *
746
+ * ```gherkin
747
+ * When I exit iframe
748
+ * ```
749
+ *
750
+ * @example
751
+ * When I switch to iframe with selector "#my-iframe"
752
+ * And I fill "my data"
753
+ * When I exit iframe
754
+ * And I click "Main Page Button"
755
+ *
756
+ * @remarks
757
+ * This step is crucial for navigating back to the main document after interacting
758
+ * with elements inside an iframe. It sets `this.frame` back to `undefined` (or the main page locator).
759
+ * @category IFrame Steps
760
+ */
761
+ export function When_I_exit_iframe(this: CustomWorld) {
762
+ this.exitIframe(); // Assuming CustomWorld has an exitIframe method
763
+ this.log?.(`🪟 Exited iframe context, now interacting with the main page.`);
764
+ }
765
+ When("I exit iframe", When_I_exit_iframe);
243
766
 
244
- //
245
- // 🔁 Reusable actions on stored elements
246
- //
767
+ // ===================================================================================
768
+ // UTILITY ACTIONS: REUSABLE ACTIONS ON STORED ELEMENTS
769
+ // ===================================================================================
247
770
 
771
+ // Helper functions (keep these outside the exports or as internal helpers)
248
772
  function toOrdinal(n: number) {
249
773
  const s = ["th", "st", "nd", "rd"];
250
774
  const v = n % 100;
@@ -260,10 +784,15 @@ async function getReadableLabel(el: Locator): Promise<string> {
260
784
  }
261
785
  }
262
786
 
263
- async function getElementsSubset(world: CustomWorld, mode: string, count: number) {
787
+ // Helper to get a subset of elements (first, last, random, or specific nth for action)
788
+ async function getElementsSubset(
789
+ world: CustomWorld,
790
+ mode: string,
791
+ count: number
792
+ ): Promise<Locator[]> {
264
793
  const total = await world.elements?.count();
265
- if (!total || total < 1) throw new Error("No elements stored");
266
- if (count > total) throw new Error(`Only ${total} elements available`);
794
+ if (!total || total < 1) throw new Error("No elements stored in 'this.elements' collection.");
795
+ if (count > total) throw new Error(`Cannot get ${count} elements, only ${total} available.`);
267
796
 
268
797
  switch (mode) {
269
798
  case "first":
@@ -271,17 +800,26 @@ async function getElementsSubset(world: CustomWorld, mode: string, count: number
271
800
  case "last":
272
801
  return Array.from({ length: count }, (_, i) => world.elements!.nth(total - count + i));
273
802
  case "random":
274
- return Array.from({ length: count }, () =>
275
- world.elements!.nth(Math.floor(Math.random() * total))
276
- );
803
+ // Generate unique random indices
804
+ const indices = new Set<number>();
805
+ while (indices.size < count) {
806
+ indices.add(Math.floor(Math.random() * total));
807
+ }
808
+ return Array.from(indices).map((i) => world.elements!.nth(i));
809
+ case "nth": // Used specifically by the "I (action) the Nth element" step
810
+ if (count < 1) throw new Error(`Invalid Nth element index: ${count}. Must be 1 or greater.`);
811
+ if (count > total)
812
+ throw new Error(`Cannot get ${toOrdinal(count)} element, only ${total} available.`);
813
+ return [world.elements!.nth(count - 1)]; // Return as array for consistent loop below
277
814
  default:
278
- throw new Error(`Unsupported mode: ${mode}`);
815
+ throw new Error(`Unsupported subset mode: "${mode}".`);
279
816
  }
280
817
  }
281
818
 
819
+ // Define the supported actions and their display names for logging
282
820
  type LocatorAction = "click" | "hover" | "check" | "uncheck" | "focus" | "blur" | "fill";
283
821
 
284
- const actionDisplayNames: Record<string, string> = {
822
+ const actionDisplayNames: Record<LocatorAction, string> = {
285
823
  click: "Clicked",
286
824
  hover: "Hovered",
287
825
  check: "Checked",
@@ -291,6 +829,7 @@ const actionDisplayNames: Record<string, string> = {
291
829
  fill: "Filled",
292
830
  };
293
831
 
832
+ // Define the functions that perform the Playwright actions on a locator
294
833
  const locatorActions: Record<LocatorAction, (el: Locator, table?: DataTable) => Promise<void>> = {
295
834
  click: (el, table) => el.click(parseClickOptions(table)),
296
835
  hover: (el, table) => el.hover(parseHoverOptions(table)),
@@ -298,117 +837,308 @@ const locatorActions: Record<LocatorAction, (el: Locator, table?: DataTable) =>
298
837
  uncheck: (el, table) => el.uncheck(parseUncheckOptions(table)),
299
838
  focus: (el) => el.focus(),
300
839
  blur: (el) => el.evaluate((e: HTMLElement) => e.blur()),
301
- fill: (el, table) => el.fill("", parseFillOptions(table)), // Extend this to support value
840
+ fill: (el, table) => el.fill("", parseFillOptions(table)), // This `fill` is generic. If you need to fill with a specific value from the step,
841
+ // you'll need to pass it as an argument to the actionFn in the step definition.
842
+ // For now, it's just clearing.
302
843
  };
303
844
 
304
- When(
305
- /^I (\w+) the (first|last|random) (\d+)$/,
306
- async function (
307
- this: CustomWorld,
308
- action: string,
309
- mode: string,
310
- count: number,
311
- table?: DataTable
312
- ) {
313
- const elements = await getElementsSubset(this, mode, count);
314
- const actionFn = locatorActions[action as LocatorAction];
315
-
316
- if (!actionFn) throw new Error(`Unsupported action: ${action}`);
317
- for (const el of elements) {
318
- const label = await getReadableLabel(el);
319
- await actionFn(el, table);
320
- this.log?.(`✅ ${actionDisplayNames[action] || action} element: "${label}"`);
321
- }
322
- }
323
- );
845
+ /**
846
+ * Performs a specified action (e.g., click, hover, check, uncheck, focus, blur)
847
+ * on a subset of the previously stored elements (first N, last N, or random N).
848
+ *
849
+ * ```gherkin
850
+ * When I {word} the {word} {int}
851
+ * ```
852
+ *
853
+ * @param action - The action to perform (e.g., "click", "hover", "check", "uncheck", "focus", "blur", "fill").
854
+ * @param mode - The selection mode: "first", "last", or "random".
855
+ * @param count - The number of elements to apply the action to.
856
+ * @param table - (Optional) A Cucumber DataTable for action-specific options (e.g., `ClickOptions`).
857
+ *
858
+ * @example
859
+ * Given I find elements by selector ".item-checkbox"
860
+ * When I check the first 2
861
+ * When I hover the last 3
862
+ *
863
+ * @remarks
864
+ * This step requires that `this.elements` (a Playwright `Locator` that points to multiple
865
+ * elements) has been populated by a preceding step (e.g., `When I find elements by selector`).
866
+ * The `action` must be one of the supported actions. The `count` specifies how many of
867
+ * the matched elements to target.
868
+ * @category Multi-Element Action Steps
869
+ */
870
+ export async function When_I_perform_action_on_subset_of_elements(
871
+ this: CustomWorld,
872
+ action: string,
873
+ mode: string,
874
+ count: number,
875
+ table?: DataTable
876
+ ) {
877
+ const elements = await getElementsSubset(this, mode, count);
878
+ const actionFn = locatorActions[action as LocatorAction];
324
879
 
325
- When(
326
- /^I (\w+) the (\d+)(?:st|nd|rd|th) element$/,
327
- async function (this: CustomWorld, action: string, nth: number, table?: DataTable) {
328
- const elements = await getElementsSubset(this, "nth", nth);
329
- const actionFn = locatorActions[action as LocatorAction];
330
-
331
- if (!actionFn) throw new Error(`Unsupported action: ${action}`);
332
- for (const el of elements) {
333
- const label = await getReadableLabel(el);
334
- await actionFn(el, table);
335
- this.log?.(
336
- `✅ ${actionDisplayNames[action] || action} the ${toOrdinal(nth)} element: "${label}"`
337
- );
338
- }
880
+ if (!actionFn) throw new Error(`Unsupported action: "${action}".`);
881
+
882
+ for (const el of elements) {
883
+ const label = await getReadableLabel(el);
884
+ await actionFn(el, table);
885
+ this.log?.(`✅ ${actionDisplayNames[action as LocatorAction] || action} element: "${label}".`);
339
886
  }
340
- );
341
- When("I press key {string}", async function (this: CustomWorld, key: string) {
342
- if (!this.element) throw new Error("No element selected");
887
+ }
888
+ When(/^I (\w+) the (first|last|random) (\d+)$/, When_I_perform_action_on_subset_of_elements);
889
+
890
+ /**
891
+ * Performs a specified action (e.g., click, hover, check, uncheck, focus, blur)
892
+ * on the Nth element of the previously stored elements collection.
893
+ *
894
+ * ```gherkin
895
+ * When I {word} the {int}(?:st|nd|rd|th) element
896
+ * ```
897
+ *
898
+ * @param action - The action to perform (e.g., "click", "hover", "check", "uncheck", "focus", "blur", "fill").
899
+ * @param nth - The 1-based index of the element to target (e.g., 1 for 1st, 2 for 2nd).
900
+ * @param table - (Optional) A Cucumber DataTable for action-specific options.
901
+ *
902
+ * @example
903
+ * Given I find elements by selector ".product-card"
904
+ * When I click the 2nd element
905
+ * When I fill the 1st element
906
+ *
907
+ * @remarks
908
+ * This step requires that `this.elements` has been populated by a preceding step.
909
+ * It targets a single element at a specific 1-based ordinal position within that collection.
910
+ * The `action` must be one of the supported actions.
911
+ * @category Multi-Element Action Steps
912
+ */
913
+ export async function When_I_perform_action_on_nth_element(
914
+ this: CustomWorld,
915
+ action: string,
916
+ nth: number,
917
+ table?: DataTable
918
+ ) {
919
+ // Use "nth" mode with getElementsSubset to correctly fetch a single element
920
+ const elements = await getElementsSubset(this, "nth", nth); // This will return an array with one element
921
+ const targetElement = elements[0]; // Get the single element
922
+
923
+ const actionFn = locatorActions[action as LocatorAction];
924
+ if (!actionFn) throw new Error(`Unsupported action: "${action}".`);
925
+
926
+ const label = await getReadableLabel(targetElement);
927
+ await actionFn(targetElement, table);
928
+ this.log?.(
929
+ `✅ ${actionDisplayNames[action as LocatorAction] || action} the ${toOrdinal(nth)} element: "${label}".`
930
+ );
931
+ }
932
+ When(/^I (\w+) the (\d+)(?:st|nd|rd|th) element$/, When_I_perform_action_on_nth_element);
933
+
934
+ /**
935
+ * Presses a specific key on the previously selected element.
936
+ *
937
+ * ```gherkin
938
+ * When I press key {string}
939
+ * ```
940
+ *
941
+ * @param key - The key to press (e.g., "Enter", "Escape", "ArrowDown", "Tab").
942
+ *
943
+ * @example
944
+ * When I find element by selector "input[name='email']"
945
+ * And I type "my query"
946
+ * When I press key "Enter"
947
+ *
948
+ * @remarks
949
+ * This step requires a preceding step that sets the {@link CustomWorld.element | current element}.
950
+ * It first focuses the element and then simulates a key press.
951
+ * This is useful for triggering keyboard shortcuts or submitting forms via "Enter".
952
+ * @category Keyboard Interaction Steps
953
+ */
954
+ export async function When_I_press_key(this: CustomWorld, key: string) {
955
+ if (!this.element) throw new Error("No element selected to press key on.");
343
956
 
344
957
  await this.element.focus();
345
- await this.page.waitForTimeout(100); // buffer
958
+ await this.page.waitForTimeout(50); // Small buffer to ensure focus
346
959
  await this.element.press(key);
347
- this.log?.(`🎹 Pressed {${key}}`);
348
- });
960
+ this.log?.(`🎹 Pressed key "{${key}}" on selected element.`);
961
+ }
962
+ When("I press key {string}", When_I_press_key);
349
963
 
350
- When(
351
- /^I set viewport to "([^"]+)"(?: and "([^"]+)")?$/,
352
- async function (this: CustomWorld, deviceInput: string, orientation?: string) {
353
- const normalizedDevice = normalizeDeviceName(deviceInput);
354
- if (!normalizedDevice) {
355
- throw new Error(`🚫 Unknown device: "${deviceInput}"`);
356
- }
357
-
358
- const baseDevice = devices[normalizedDevice];
359
- if (!baseDevice) {
360
- throw new Error(`🚫 Device not found: "${normalizedDevice}"`);
361
- }
362
-
363
- const isLandscape = orientation?.toLowerCase() === "landscape";
364
- const deviceSettings: BrowserContextOptions = isLandscape
365
- ? (baseDevice as any).landscape
366
- ? (baseDevice as any).landscape
367
- : {
368
- ...baseDevice,
369
- isMobile: true,
370
- viewport: { ...baseDevice.viewport, isLandscape: true },
371
- }
372
- : baseDevice;
373
-
374
- // Close current context if needed
375
- if (this.context) {
376
- await this.context.close();
377
- }
378
-
379
- this.context = await this.browser.newContext(deviceSettings);
380
- this.page = await this.context.newPage();
381
-
382
- this.log?.(`📱 Set viewport to ${normalizedDevice}${isLandscape ? " in landscape" : ""}`);
964
+ // ===================================================================================
965
+ // UTILITY ACTIONS: VIEWPORT
966
+ // ===================================================================================
967
+
968
+ /**
969
+ * Sets the browser viewport to emulate a specific Playwright device profile and orientation.
970
+ * This will close the current browser context and open a new one with the specified device settings.
971
+ *
972
+ * ```gherkin
973
+ * When I set viewport to {string}
974
+ * When I set viewport to {string} and {string}
975
+ * ```
976
+ *
977
+ * @param deviceInput - The name of the Playwright device (e.g., "iPhone 12", "iPad", "Desktop Chrome").
978
+ * @param orientation - (Optional) The orientation, either "landscape" or "portrait" (default if not specified).
979
+ *
980
+ * @example
981
+ * When I set viewport to "iPhone 12"
982
+ * When I set viewport to "iPad" and "landscape"
983
+ *
984
+ * @remarks
985
+ * This step creates a *new* browser context and page, so any previous page state or
986
+ * setup (like routes, localStorage) will be reset.
987
+ * The `deviceInput` is normalized to match Playwright's `devices` object keys.
988
+ * @category Browser Context Steps
989
+ */
990
+ export async function When_I_set_viewport_to_device(
991
+ this: CustomWorld,
992
+ deviceInput: string,
993
+ orientation?: string
994
+ ) {
995
+ const normalizedDevice = normalizeDeviceName(deviceInput);
996
+ if (!normalizedDevice) {
997
+ throw new Error(
998
+ `🚫 Unknown device name: "${deviceInput}". Check Playwright 'devices' for valid names.`
999
+ );
383
1000
  }
384
- );
385
1001
 
386
- When(
387
- "I set viewport to {int}px by {int}px",
388
- async function (this: CustomWorld, width: number, height: number) {
389
- // Close existing context
390
- if (this.context) {
391
- await this.context.close();
392
- }
393
-
394
- // Recreate new context with the desired viewport
395
- this.context = await this.browser.newContext({
396
- viewport: { width, height },
397
- });
1002
+ const baseDevice = devices[normalizedDevice];
1003
+ if (!baseDevice) {
1004
+ throw new Error(`🚫 Playwright device not found for normalized name: "${normalizedDevice}".`);
1005
+ }
398
1006
 
399
- this.page = await this.context.newPage();
1007
+ const isLandscape = orientation?.toLowerCase() === "landscape";
1008
+ const deviceSettings: BrowserContextOptions = isLandscape
1009
+ ? (baseDevice as any).landscape // Use Playwright's built-in landscape config if available
1010
+ ? (baseDevice as any).landscape
1011
+ : {
1012
+ // Otherwise, manually adjust for landscape
1013
+ ...baseDevice,
1014
+ isMobile: true, // Assuming mobile devices for landscape adjustment
1015
+ viewport: {
1016
+ width: baseDevice.viewport.height, // Swap width and height for landscape
1017
+ height: baseDevice.viewport.width,
1018
+ },
1019
+ }
1020
+ : baseDevice; // Use base device settings for portrait or non-mobile
400
1021
 
401
- this.log?.(`🖥️ Set viewport to ${width}x${height}`);
402
- }
403
- );
404
- // Dynamic Playwright Config Setters (for page-only config)
1022
+ // Close current context and page before creating a new one
1023
+ if (this.page) await this.page.close();
1024
+ if (this.context) await this.context.close();
405
1025
 
406
- When('I set Playwright config "{word}" to {string}', async function (key: string, value: string) {
1026
+ this.context = await this.browser.newContext(deviceSettings);
1027
+ this.page = await this.context.newPage();
1028
+
1029
+ this.log?.(`📱 Set viewport to ${normalizedDevice}${isLandscape ? " in landscape" : ""}.`);
1030
+ }
1031
+ When(/^I set viewport to "([^"]+)"(?: and "([^"]+)")?$/, When_I_set_viewport_to_device);
1032
+
1033
+ /**
1034
+ * Sets the viewport to the given width and height in pixels.
1035
+ * This will close the current browser context and open a new one with the specified dimensions.
1036
+ *
1037
+ * ```gherkin
1038
+ * When I set viewport to {int}px by {int}px
1039
+ * ```
1040
+ *
1041
+ * @param width - The desired viewport width in pixels.
1042
+ * @param height - The desired viewport height in pixels.
1043
+ *
1044
+ * @example
1045
+ * When I set viewport to 1280px by 720px
1046
+ *
1047
+ * @remarks
1048
+ * This step creates a *new* browser context and page, so any previous page state or
1049
+ * setup (like routes, localStorage) will be reset.
1050
+ * @category Browser Context Steps
1051
+ */
1052
+ export async function When_I_set_viewport_to_dimensions(
1053
+ this: CustomWorld,
1054
+ width: number,
1055
+ height: number
1056
+ ) {
1057
+ // Close current context and page before creating a new one
1058
+ if (this.page) await this.page.close();
1059
+ if (this.context) await this.context.close();
1060
+
1061
+ // Recreate new context with the desired viewport
1062
+ this.context = await this.browser.newContext({
1063
+ viewport: { width, height },
1064
+ });
1065
+
1066
+ this.page = await this.context.newPage();
1067
+
1068
+ this.log?.(`🖥️ Set viewport to ${width}px by ${height}px.`);
1069
+ }
1070
+ When("I set viewport to {int}px by {int}px", When_I_set_viewport_to_dimensions);
1071
+
1072
+ // ===================================================================================
1073
+ // UTILITY ACTIONS: DYNAMIC PLAYWRIGHT CONFIG SETTERS (FOR PAGE-ONLY CONFIG)
1074
+ // ===================================================================================
1075
+
1076
+ /**
1077
+ * Sets a specific Playwright page configuration property to the given value.
1078
+ * This can be used to dynamically change page-level settings during a test.
1079
+ *
1080
+ * ```gherkin
1081
+ * When I set Playwright config {word} to {string}
1082
+ * ```
1083
+ *
1084
+ * @param key - The name of the Playwright `Page` property to set (e.g., "userAgent", "defaultTimeout").
1085
+ * @param value - The string value to set the property to. Note: All values are treated as strings.
1086
+ *
1087
+ * @example
1088
+ * When I set Playwright config "userAgent" to "MyCustomAgent"
1089
+ *
1090
+ * @remarks
1091
+ * This step directly assigns a value to a property on the `this.page` object.
1092
+ * It's important to know which properties are settable and what their expected
1093
+ * types are. Using incorrect keys or values may lead to unexpected behavior or errors.
1094
+ * Not all Playwright page properties are designed to be set this way after page creation.
1095
+ * @category Configuration Steps
1096
+ */
1097
+ export async function When_I_set_playwright_page_config_key(
1098
+ this: CustomWorld,
1099
+ key: string,
1100
+ value: string
1101
+ ) {
1102
+ // Directly assign property. Using 'as any' to bypass strict type checking,
1103
+ // but be cautious as not all page properties are meant to be set this way dynamically.
407
1104
  (this.page as any)[key] = value;
408
- });
1105
+ this.log?.(`⚙️ Set Playwright page config "${key}" to "${value}".`);
1106
+ }
1107
+ When('I set Playwright config "{word}" to {string}', When_I_set_playwright_page_config_key);
409
1108
 
410
- When("I set Playwright config", async function (table: DataTable) {
1109
+ /**
1110
+ * Sets multiple Playwright page configuration properties using a data table.
1111
+ *
1112
+ * ```gherkin
1113
+ * When I set Playwright config
1114
+ * | key | value |
1115
+ * | userAgent | MyAgent |
1116
+ * | defaultTimeout | 5000 |
1117
+ * ```
1118
+ *
1119
+ * @param table - A Cucumber DataTable with two columns: `key` (the property name)
1120
+ * and `value` (the string value to set).
1121
+ *
1122
+ * @example
1123
+ * When I set Playwright config
1124
+ * | key | value |
1125
+ * | userAgent | TestBot |
1126
+ * | defaultTimeout | 10000 |
1127
+ *
1128
+ * @remarks
1129
+ * Similar to the single-key version, this step dynamically assigns values to
1130
+ * properties on the `this.page` object. All values from the data table are
1131
+ * treated as strings. Use with caution, understanding which properties can
1132
+ * be dynamically set.
1133
+ * @category Configuration Steps
1134
+ */
1135
+ export async function When_I_set_playwright_page_config_from_table(
1136
+ this: CustomWorld,
1137
+ table: DataTable
1138
+ ) {
411
1139
  for (const [key, value] of table.rows()) {
412
- (this.page as any)[key] = value;
1140
+ (this.page as any)[key] = value; // Direct assignment with 'as any'
1141
+ this.log?.(`⚙️ Set Playwright page config "${key}" to "${value}".`);
413
1142
  }
414
- });
1143
+ }
1144
+ When("I set Playwright config", When_I_set_playwright_page_config_from_table);