@capillarytech/cap-ui-utils 3.0.20 → 3.1.0

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.
@@ -0,0 +1,757 @@
1
+ import { strict as assert } from "assert";
2
+
3
+ class ElementUtils {
4
+ async hoverToReveal(element:WebdriverIO.Element){
5
+ await element.waitForDisplayed({ timeout: 60000 });
6
+ await element.moveTo();
7
+ await browser.execute((el) => {
8
+ ['mouseenter','mouseover','mousemove'].forEach((t) =>
9
+ el.dispatchEvent(new MouseEvent(t, { bubbles: true })));
10
+ }, element);
11
+ await browser.pause(400);
12
+ }
13
+
14
+ async elementClick(element:WebdriverIO.Element){
15
+ await browser.pause(2000)
16
+ await element.waitForDisplayed({ timeout: 90000})
17
+ try{
18
+ await element.waitForClickable({ timeout: 60000, interval:1000, timeoutMsg:await element.getHTML(false)+" is still not clickable" })
19
+ await element.click()
20
+ }
21
+ catch(error){
22
+ await this.forceClick(element)
23
+ }
24
+ }
25
+
26
+ /**
27
+ * Scrolls the element to viewport center and clicks it via a dispatched
28
+ * pointer/mouse-event sequence — bypasses WDIO's isClickable coverage check.
29
+ * Use when an element is displayed but reports "not clickable" because an
30
+ * overlay (info banner, lingering popover) sits over its centre point, or
31
+ * antd selects that open on mousedown rather than click.
32
+ */
33
+ async forceClick(element:WebdriverIO.Element){
34
+ const fresh = await this.waitForDisplayedStaleSafe(element, 90000);
35
+ await browser.execute((el: any) => {
36
+ el.scrollIntoView({ block: "center", inline: "center" });
37
+ ['pointerdown','mousedown','pointerup','mouseup','click'].forEach((t) =>
38
+ el.dispatchEvent(new MouseEvent(t, { bubbles: true, cancelable: true, view: window })));
39
+ }, fresh);
40
+ await browser.pause(500);
41
+ }
42
+
43
+ /**
44
+ * Re-fetches a fresh element from its original selector. A detached ("stale")
45
+ * handle — left behind when React/antd re-renders the node — is replaced by a
46
+ * live one. Falls back to the original reference for elements without a plain
47
+ * string selector (e.g. chained/derived elements).
48
+ */
49
+ private async refetch(element: WebdriverIO.Element): Promise<WebdriverIO.Element> {
50
+ // `.selector` on an un-awaited ChainablePromiseElement (e.g. a page getter
51
+ // passed straight through, as every current caller does) resolves lazily —
52
+ // reading it as a promise, not a sync property, prevents this from always
53
+ // seeing a Promise object and silently skipping the re-query.
54
+ const selector = await (element as any)?.selector;
55
+ return typeof selector === "string" ? await $(selector) : element;
56
+ }
57
+
58
+ /**
59
+ * waitForDisplayed that survives the target re-rendering mid-wait, returning
60
+ * the live handle it last confirmed. WDIO's built-in waitForDisplayed polls
61
+ * one resolved handle for the whole timeout window; if the node gets
62
+ * swapped out from under it (modal mount/zoom transition still settling,
63
+ * a button's loading-spinner state toggling) that throws "stale element
64
+ * reference" instead of just waiting longer. Re-fetch and keep waiting
65
+ * until the timeout budget is spent, so callers (forceClick in particular)
66
+ * get a fresh, currently-attached element instead of one that already went
67
+ * stale during the wait.
68
+ */
69
+ private async waitForDisplayedStaleSafe(element: WebdriverIO.Element, timeout: number = 90000): Promise<WebdriverIO.Element> {
70
+ const start = Date.now();
71
+ while (Date.now() - start < timeout) {
72
+ const el = await this.refetch(element);
73
+ const remaining = Math.max(timeout - (Date.now() - start), 1000);
74
+ const outcome = await el
75
+ .waitForDisplayed({ timeout: remaining, interval: 500 })
76
+ .then(() => "displayed")
77
+ .catch((error) =>
78
+ String((error as any)?.message ?? error).includes("stale element reference")
79
+ ? "stale"
80
+ : "timeout"
81
+ );
82
+ if (outcome === "displayed") return el;
83
+ if (outcome === "timeout") throw new Error("Element not displayed within timeout");
84
+ await browser.pause(300); // stale → re-query and keep waiting
85
+ }
86
+ throw new Error("Element kept going stale while waiting for it to be displayed");
87
+ }
88
+
89
+ /**
90
+ * Clicks an element, retrying against a freshly re-queried node whenever the
91
+ * DOM re-renders mid-interaction and throws "stale element reference" (antd
92
+ * modal mount/zoom transition, React reconcile). On a genuine non-stale
93
+ * failure it falls back to a forced click. Throws only if nothing succeeds
94
+ * within `timeout`.
95
+ */
96
+ private async clickWithStaleRetry(element: WebdriverIO.Element, timeout: number = 40000): Promise<void> {
97
+ const start = Date.now();
98
+ // Uses promise .catch() rather than nested try/catch blocks: a genuine
99
+ // failure still propagates cleanly to WDIO so the failure screenshot is
100
+ // captured (nested catches were breaking that earlier).
101
+ while (Date.now() - start < timeout) {
102
+ const el = await this.refetch(element);
103
+ const outcome = await el
104
+ .waitForClickable({ timeout: 10000, interval: 500 })
105
+ .then(() => el.click())
106
+ .then(() => "clicked")
107
+ .catch((error) =>
108
+ String((error as any)?.message ?? error).includes("stale element reference")
109
+ ? "stale"
110
+ : "unclickable"
111
+ );
112
+ if (outcome === "clicked") return;
113
+ if (outcome === "unclickable") break; // genuine (non-stale) failure → forced click
114
+ await browser.pause(500); // stale → re-query and retry
115
+ }
116
+ // Single fallback: forceClick re-fetches and stale-waits internally, so
117
+ // it survives the target still churning after the loop above gave up.
118
+ // No try/catch — a genuine failure here should throw and surface the
119
+ // real error to the test (and its screenshot).
120
+ await this.forceClick(element);
121
+ }
122
+
123
+ /**
124
+ * Expands an antd Collapse (accordion) left-nav section by its header label,
125
+ * then confirms `confirmChild` became visible.
126
+ *
127
+ * The toggle handler lives on the `.ant-collapse-header` row (role="tab"); a
128
+ * native WebDriver click on the inner <span> does NOT reliably fire it (the
129
+ * click lands at the row centre), so the panel stays collapsed and its items
130
+ * remain present-but-hidden — which is why waitForDisplayed on a child times
131
+ * out. We instead JS-click the header element (which reliably fires antd's
132
+ * toggle), gate each click on aria-expanded (re-clicking an OPEN panel would
133
+ * collapse it), and retry up to `attempts` times until the child is visible.
134
+ *
135
+ * This is the shared version of the fix originally inlined in kpi.page
136
+ * navigateLibrary; use it for every left-nav section (Library, Data connectors,
137
+ * User segments, …). `label` is the visible header text; `confirmChild` is any
138
+ * element that only becomes visible once the section is expanded.
139
+ */
140
+ async expandSideNavSection(label: string, confirmChild: WebdriverIO.Element, attempts = 3): Promise<void> {
141
+ // The header label may be a <span> (top-level sections) or a <div> (nested
142
+ // sub-accordions like "Status"), so match any element by its text.
143
+ const header = await $(
144
+ `//div[@role="tab" and contains(@class,"ant-collapse-header")][.//*[normalize-space(text())="${label}"]]`
145
+ );
146
+ await header.waitForDisplayed({ timeout: 60000 });
147
+ for (let attempt = 1; attempt <= attempts; attempt++) {
148
+ const expanded = (await header.getAttribute("aria-expanded")) === "true";
149
+ if (!expanded) {
150
+ await header.waitForClickable({ timeout: 40000 });
151
+ await browser.execute((el: any) => el.click(), header);
152
+ console.log(`Expanded side-nav "${label}" (attempt ${attempt})`);
153
+ }
154
+ const visible = await confirmChild
155
+ .waitForDisplayed({ timeout: 20000 })
156
+ .then(() => true)
157
+ .catch(() => false);
158
+ if (visible) {
159
+ console.log(`Side-nav "${label}" expanded — content visible`);
160
+ return;
161
+ }
162
+ }
163
+ throw new Error(`Side-nav section "${label}" did not expand to reveal its content`);
164
+ }
165
+
166
+ /**
167
+ * Opens a leaf item that lives inside a left-nav antd Collapse section:
168
+ * expands the section (idempotent) via expandSideNavSection, then clicks the
169
+ * leaf. The Insights nav can re-render on late API content and collapse a
170
+ * just-expanded section, so the whole expand+click is RETRIED — a mid-flow
171
+ * collapse (leaf visible on expand, gone by click time) self-heals on the next
172
+ * attempt. Use for the "expand section -> click child" pattern
173
+ * (Library -> Charts, Data connectors -> Export schedules, User segments -> Status).
174
+ */
175
+ async openSideNavItem(sectionLabel: string, leaf: WebdriverIO.Element, attempts = 3): Promise<void> {
176
+ let lastError: unknown;
177
+ for (let attempt = 1; attempt <= attempts; attempt++) {
178
+ try {
179
+ await this.expandSideNavSection(sectionLabel, leaf);
180
+ await leaf.waitForClickable({ timeout: 15000 });
181
+ await leaf.click();
182
+ console.log(`Opened "${sectionLabel}" item on attempt ${attempt}`);
183
+ return;
184
+ } catch (error) {
185
+ lastError = error;
186
+ console.log(`openSideNavItem "${sectionLabel}": attempt ${attempt}/${attempts} failed (${error}) — re-expanding`);
187
+ await browser.pause(2000);
188
+ }
189
+ }
190
+ throw new Error(`openSideNavItem: could not open item under "${sectionLabel}" after ${attempts} attempts. Last error: ${lastError}`);
191
+ }
192
+
193
+ async enterText(element:WebdriverIO.Element, textToEnter){
194
+ await browser.pause(1000)
195
+ // browser.execute('arguments[0].style.outline = "#f00 solid 4px";', element);
196
+ await element.waitForEnabled({ timeout: 40000, interval:1000, timeoutMsg:await element.getHTML(false)+" is still not editable" })
197
+ await element.setValue(textToEnter)
198
+ }
199
+
200
+ /**
201
+ * Types text using addValue (key events). Use for React controlled inputs
202
+ * where setValue does not update state.
203
+ */
204
+ async typeText(element: WebdriverIO.Element, textToEnter: string) {
205
+ await browser.pause(1000);
206
+ await element.waitForEnabled({
207
+ timeout: 40000,
208
+ interval: 1000,
209
+ timeoutMsg: await element.getHTML(false) + " is still not editable"
210
+ });
211
+ await element.addValue(textToEnter);
212
+ }
213
+
214
+ /**
215
+ * Selects a date by clicking through the antd calendar UI instead of
216
+ * typing into the input. Some DatePicker instances only accept a value
217
+ * chosen from their own calendar grid — typed/pasted text never commits
218
+ * (the input looks like it accepted it, but nothing was actually
219
+ * selected).
220
+ *
221
+ * The target day is usually already visible as a grayed spillover cell at
222
+ * the edge of the current month's grid (e.g. "1" shown right after "31")
223
+ * — antd's `<td>` cells carry a `title="YYYY-MM-DD"` attribute regardless
224
+ * of which month they spill over from, so clicking that cell directly
225
+ * both selects the date AND navigates the panel to its month. If the
226
+ * target is further out and no such cell is on screen at all, this falls
227
+ * back to walking the calendar header forward/back one month at a time
228
+ * (via the next/prev-month buttons) until the target month is showing,
229
+ * then clicks the day cell there.
230
+ */
231
+ async selectCalendarDate(dateInput: WebdriverIO.Element, targetDate: Date, label = "date field") {
232
+ const isoDate = `${targetDate.getFullYear()}-${String(targetDate.getMonth() + 1).padStart(2, "0")}-${String(targetDate.getDate()).padStart(2, "0")}`;
233
+ const expectedDisplay = targetDate.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
234
+
235
+ await this.retryStep(async () => {
236
+ const input = await this.refetch(dateInput);
237
+ await input.waitForClickable({ timeout: 40000 });
238
+ await input.click();
239
+
240
+ const panel = await $('//div[contains(@class,"ant-picker-dropdown") and not(contains(@class,"ant-picker-dropdown-hidden"))]');
241
+ await panel.waitForDisplayed({ timeout: 10000 });
242
+
243
+ let dayCell = await panel.$(`.//td[@title="${isoDate}"]`);
244
+ const alreadyVisible = await dayCell.isDisplayed().catch(() => false);
245
+
246
+ if (!alreadyVisible) {
247
+ const headerView = await panel.$(".ant-picker-header-view");
248
+ const nextBtn = await panel.$(".ant-picker-header-next-btn");
249
+ const prevBtn = await panel.$(".ant-picker-header-prev-btn");
250
+ const targetMonths = targetDate.getFullYear() * 12 + targetDate.getMonth();
251
+
252
+ for (let attempt = 0; attempt < 24; attempt++) {
253
+ const shownText = (await headerView.getText()).trim(); // e.g. "Jul 2026"
254
+ const shownDate = new Date(`1 ${shownText}`);
255
+ const shownMonths = shownDate.getFullYear() * 12 + shownDate.getMonth();
256
+ const diff = targetMonths - shownMonths;
257
+ if (diff === 0) break;
258
+ if (attempt === 23) {
259
+ throw new Error(`${label}: could not navigate calendar to target month (stuck on "${shownText}")`);
260
+ }
261
+ await (diff > 0 ? nextBtn : prevBtn).click();
262
+ await browser.pause(300);
263
+ }
264
+ dayCell = await panel.$(`.//td[@title="${isoDate}"]`);
265
+ }
266
+
267
+ await dayCell.waitForClickable({ timeout: 10000 });
268
+ await dayCell.click();
269
+
270
+ await panel.waitForDisplayed({ timeout: 5000, reverse: true });
271
+
272
+ const committed = await input.getValue();
273
+ if (committed !== expectedDisplay) {
274
+ throw new Error(`${label} did not commit: expected "${expectedDisplay}", got "${committed}"`);
275
+ }
276
+ console.log(`${label} set to ${expectedDisplay} via calendar click`);
277
+ }, {
278
+ maxAttempts: 3,
279
+ label,
280
+ beforeRetry: async () => {
281
+ await browser.keys("Escape");
282
+ },
283
+ });
284
+ }
285
+
286
+ /**
287
+ * Writes via the native HTMLInputElement value setter, then dispatches an
288
+ * `input` event. Bypasses React's `_valueTracker`, so React commits the
289
+ * new value as a single state update.
290
+ *
291
+ * WebDriver's char-by-char typing races with antd v6 / React 18 controlled
292
+ * inputs and drops every character after the first. This injection avoids
293
+ * that race entirely.
294
+ *
295
+ * Accepts either a CSS `selector` string or an already-resolved WDIO
296
+ * element. With a selector, `alsoDispatch` adds extra events after `input`
297
+ * (e.g. `["change", "blur"]`) when the field's logic relies on them
298
+ * (validation, commit-on-blur, etc). With an element, it first waits for the
299
+ * field to be editable, then dispatches `change` + blur after `input`.
300
+ */
301
+ async setReactInputValue(
302
+ target: string | WebdriverIO.Element,
303
+ value: string,
304
+ alsoDispatch: Array<"change" | "blur"> = [],
305
+ ) {
306
+ if (typeof target === "string") {
307
+ await browser.execute(
308
+ (sel: string, val: string, extras: string[]) => {
309
+ const input = document.querySelector(sel) as HTMLInputElement | null;
310
+ if (!input) {
311
+ throw new Error(`setReactInputValue: no element matches selector "${sel}"`);
312
+ }
313
+ const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')!.set!;
314
+ setter.call(input, val);
315
+ input.dispatchEvent(new Event('input', { bubbles: true }));
316
+ for (const name of extras) {
317
+ input.dispatchEvent(new Event(name, { bubbles: true }));
318
+ }
319
+ },
320
+ target,
321
+ value,
322
+ alsoDispatch,
323
+ );
324
+ return;
325
+ }
326
+ await browser.pause(1000);
327
+ await target.waitForEnabled({
328
+ timeout: 40000,
329
+ interval: 1000,
330
+ timeoutMsg: await target.getHTML(false) + " is still not editable"
331
+ });
332
+ await browser.execute((el: any, val: string) => {
333
+ const desc = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value");
334
+ if (desc && desc.set) {
335
+ desc.set.call(el, val);
336
+ } else {
337
+ el.value = val;
338
+ }
339
+ el.dispatchEvent(new Event("input", { bubbles: true }));
340
+ el.dispatchEvent(new Event("change", { bubbles: true }));
341
+ el.blur();
342
+ }, target, value);
343
+ }
344
+
345
+ async switchToUrl(url: string){
346
+ await browser.switchWindow('url')
347
+ }
348
+
349
+ async waitUntilDisplayed(element:WebdriverIO.Element, awaitedText, timeout=30000){
350
+ try {
351
+ // First wait for element to be displayed
352
+ await element.waitForDisplayed({ timeout: timeout, timeoutMsg: `Element not displayed within ${timeout} ms` });
353
+
354
+ // Then wait for the correct text
355
+ await element.waitUntil(async function () {
356
+ const currentText = await this.getText();
357
+ console.log(`Waiting for text: '${awaitedText}', Current text: '${currentText}'`);
358
+ return currentText.includes(awaitedText) || currentText === awaitedText;
359
+ }, {
360
+ timeout,
361
+ timeoutMsg: `Unable to locate the awaited text: '${awaitedText}' within ${timeout} ms`
362
+ });
363
+ } catch (error) {
364
+ console.log(`🔴 Error in waitUntilDisplayed: ${error}`);
365
+ throw error;
366
+ }
367
+ }
368
+
369
+ async waitUntilEnabled(element:WebdriverIO.Element){
370
+ await element.waitUntil(async function () {
371
+ return await element.isEnabled();
372
+ }, {
373
+ timeout: 90000,
374
+ timeoutMsg: 'Element not enabled'
375
+ });
376
+ }
377
+ async checkElemetExistsAndElementClick(element:WebdriverIO.Element){
378
+ await browser.pause(1000);
379
+ let isDisplayed = await element.isDisplayed();
380
+ if (isDisplayed) {
381
+ await element.waitForClickable({ timeout: 40000, interval:1000, timeoutMsg:await element.getHTML(false)+" is still not clickable" })
382
+ await element.click()
383
+ }
384
+ }
385
+
386
+ /**
387
+ * Stale-safe variant of checkElemetExistsAndElementClick. Opt-in helper for
388
+ * optional buttons that live inside a re-rendering container — e.g. the
389
+ * reset-optin confirmation modal, whose node is swapped by the antd mount/
390
+ * transition between the presence check and the click, throwing "stale
391
+ * element reference". Kept as a separate method so the widely-used shared
392
+ * helpers stay untouched and other modules are unaffected.
393
+ */
394
+ async checkElementExistsAndClickStaleSafe(element:WebdriverIO.Element, appearTimeout: number = 3000){
395
+ // Presence must be *waited* for, not sampled. A fixed pause + one
396
+ // isDisplayed() snapshot loses the race against an antd modal's
397
+ // mount + zoom transition: the helper returns "not there", the modal
398
+ // then opens and is never confirmed, and its mask silently intercepts
399
+ // every later click on the page (each one burning the full
400
+ // waitForClickable timeout before elementClick's forceClick fallback
401
+ // punches through it) until something without a fallback — a hover —
402
+ // finally hard-fails, several steps away from the real cause.
403
+ const fresh = await this.refetch(element);
404
+ const appeared = await fresh
405
+ .waitForDisplayed({ timeout: appearTimeout, interval: 250 })
406
+ .then(() => true)
407
+ .catch(() => false);
408
+ if (!appeared) return false;
409
+
410
+ await this.clickWithStaleRetry(element, 40000);
411
+
412
+ // Confirm the modal actually went away: if it is still up, everything
413
+ // downstream is about to fail confusingly, so fail here instead.
414
+ const dismissed = await (await this.refetch(element))
415
+ .waitForDisplayed({ reverse: true, timeout: 20000, interval: 250 })
416
+ .then(() => true)
417
+ .catch(() => false);
418
+ if (!dismissed) {
419
+ throw new Error("Confirmation modal was clicked but is still displayed — it will block subsequent steps");
420
+ }
421
+ return true;
422
+ }
423
+
424
+ /**
425
+ * Use this when the element/input is optional (not mandatory).
426
+ * Does not throw if the element is not displayed; only logs and skips entering text.
427
+ */
428
+ async checkElemetExistsAndEnterText(element: WebdriverIO.Element, textToEnter: string) {
429
+ const isDisplayed = await element.isDisplayed();
430
+ if (isDisplayed) {
431
+ await element.waitForEnabled({
432
+ timeout: 40000,
433
+ interval: 1000,
434
+ timeoutMsg: await element.getHTML(false) + " is still not editable"
435
+ });
436
+ await element.setValue(textToEnter);
437
+ }
438
+ }
439
+
440
+ async isElementDisplayed(element:WebdriverIO.Element){
441
+ await browser.pause(1000)
442
+ let isDisplayed = await element.isDisplayed();
443
+ return isDisplayed;
444
+ }
445
+
446
+ async elementHover(element: WebdriverIO.Element) {
447
+ try {
448
+ // Pause for 2 seconds before starting (optional)
449
+ await browser.pause(2000);
450
+
451
+ // Wait for the element to be displayed
452
+ await element.waitForDisplayed({ timeout: 90000 });
453
+
454
+ // Wait for the element to be interactable/clickable (often ensures it's not obscured)
455
+ await element.waitForClickable({
456
+ timeout: 40000,
457
+ interval: 1000,
458
+ timeoutMsg: await element.getHTML(false) + " is still not interactable for hover"
459
+ });
460
+
461
+ // Perform the hover action
462
+ await element.moveTo();
463
+ console.log('Move to action performed');
464
+ } catch (error) {
465
+ throw new Error(error);
466
+ }
467
+ }
468
+
469
+ /**
470
+ * Click the button only if it becomes clickable within the given timeout.
471
+ * Uses a short timeout (default 1s) to avoid waiting when the button stays disabled (e.g. after validation failure).
472
+ * Use during unmatched-braces checks: if Done/Save becomes clickable we click it; otherwise we skip and assert the error.
473
+ */
474
+ async clickIfClickable(button: WebdriverIO.Element, timeoutMs = 1000) {
475
+ try {
476
+ await button.waitForClickable({ timeout: timeoutMs });
477
+ } catch (error) {
478
+ const message = error instanceof Error ? error.message : String(error);
479
+ if (/timeout|not clickable/i.test(message)) {
480
+ // Button stayed disabled within the timeout; caller will assert validation error.
481
+ return;
482
+ }
483
+ throw error;
484
+ }
485
+
486
+ // waitForClickable succeeded — click errors propagate normally.
487
+ await button.click();
488
+ }
489
+
490
+ /**
491
+ * Clear the input's current value then type new text.
492
+ * Delegates the clear step to selectAllAndClear (select-all + Backspace) so
493
+ * React-controlled inputs see the onChange event. Only calls setValue when
494
+ * textToEnter is non-empty.
495
+ */
496
+ async clearAndEnterText(element: WebdriverIO.Element, textToEnter: string) {
497
+ await this.selectAllAndClear(element);
498
+ if (textToEnter !== '') {
499
+ await element.setValue(textToEnter);
500
+ }
501
+ }
502
+
503
+ /**
504
+ * Clear text by sending Backspace key N times (N = textToRemove.length).
505
+ * If element is provided, focus it first (click) so backspaces apply to that field.
506
+ * Use for clearing content in inputs/editors (e.g. unmatched-braces recovery in Viber, Email CodeMirror).
507
+ */
508
+ async clearByBackspace(textToRemove: string, element?: WebdriverIO.Element) {
509
+ if (element) {
510
+ await element.waitForDisplayed({ timeout: 10000 });
511
+ await element.click();
512
+ }
513
+ const count = textToRemove.length;
514
+ for (let i = 0; i < count; i++) {
515
+ await browser.keys(["Backspace"]);
516
+ }
517
+ }
518
+
519
+ /**
520
+ * Wait for a spinner to appear (optional) then disappear.
521
+ * Mirrors the pattern in deleteTemplate: catches if spinner never appears (loaded too fast),
522
+ * then waits for it to vanish before proceeding.
523
+ */
524
+ async waitForSpinnerCycle(
525
+ spinner: WebdriverIO.Element,
526
+ appearTimeoutMs = 5000,
527
+ disappearTimeoutMs = 180000
528
+ ) {
529
+ await spinner.waitForDisplayed({ timeout: appearTimeoutMs }).catch(() => {
530
+ console.log("Spinner did not appear — may have loaded too fast, proceeding");
531
+ });
532
+ await spinner.waitForDisplayed({ timeout: disappearTimeoutMs, reverse: true });
533
+ }
534
+
535
+ /**
536
+ * Best-effort loader "settle" for the blank-screen -> loader -> content
537
+ * lifecycle. Waits (advisory) for a loader to APPEAR, then to DISAPPEAR, both
538
+ * swallowed so a slow/absent/stuck loader never hard-fails the caller. A bare
539
+ * reverse-only wait races: while the page is still a blank white screen the
540
+ * loader is not yet mounted, so "wait until no loader" is already true and
541
+ * returns instantly. The appear wait closes that race; callers that need a
542
+ * hard guarantee should follow this with a positive element check (e.g.
543
+ * waitForStableVisible on real content).
544
+ */
545
+ async waitForLoaderSettle(
546
+ loader: WebdriverIO.Element,
547
+ { appearTimeoutMs = 20000, disappearTimeoutMs = 180000 } = {}
548
+ ) {
549
+ await loader
550
+ .waitForDisplayed({ timeout: appearTimeoutMs })
551
+ .catch(() => console.log(`Loader did not appear within ${appearTimeoutMs}ms — proceeding`));
552
+ await loader
553
+ .waitForDisplayed({ timeout: disappearTimeoutMs, reverse: true })
554
+ .catch(() => console.log(`Loader still present after ${disappearTimeoutMs}ms — proceeding (positive check gates)`));
555
+ }
556
+
557
+ /**
558
+ * Reusable try-catch handler function for waiting and verifying elements
559
+ */
560
+ async waitForElementAndVerify({ element, timeout = 30000, label, name }) {
561
+ try {
562
+ await element.waitForDisplayed({ timeout });
563
+ console.log(`${label} found in search results:`, name);
564
+ } catch (error) {
565
+ console.error(`${label} not found in search results:`, name);
566
+ throw new Error(`${label} "${name}" not found in search results`);
567
+ }
568
+ }
569
+
570
+ /**
571
+ * Counts visible elements in a `$$` collection and asserts it equals `expected`.
572
+ * antd v6 keeps duplicate hidden DOM nodes around, so plain `.length` isn't reliable.
573
+ */
574
+ async assertVisibleCount(
575
+ elements: WebdriverIO.ElementArray,
576
+ expected: number,
577
+ label = "elements"
578
+ ) {
579
+ let visibleCount = 0;
580
+ for (const el of elements) {
581
+ if (await el.isDisplayed().catch(() => false)) visibleCount++;
582
+ }
583
+ assert.equal(
584
+ visibleCount,
585
+ expected,
586
+ `Expected ${expected} visible ${label} but found ${visibleCount}`
587
+ );
588
+ console.log(`🟢 Verified visible ${label} count = ${visibleCount}`);
589
+ }
590
+
591
+ /**
592
+ * Polls `$$(selector)` until one match is displayed, skipping hidden duplicates
593
+ * (e.g. antd keeps inactive tab panes mounted in the DOM).
594
+ */
595
+ async firstVisibleElement(selector: string, timeout = 90000): Promise<WebdriverIO.Element> {
596
+ let found: WebdriverIO.Element | undefined;
597
+ await browser.waitUntil(
598
+ async () => {
599
+ const elements = await $$(selector);
600
+ for (const element of elements) {
601
+ if (await element.isDisplayed()) {
602
+ found = element;
603
+ return true;
604
+ }
605
+ }
606
+ return false;
607
+ },
608
+ { timeout, timeoutMsg: `No visible element matched selector: ${selector}` }
609
+ );
610
+ return found as WebdriverIO.Element;
611
+ }
612
+
613
+ /**
614
+ * Races candidates and confirms the winner is stably displayed.
615
+ * After a candidate wins the race, waits stableMs then re-checks.
616
+ * If the winner disappeared, retries the full race with remaining timeout.
617
+ * Only throws when the total timeout is exhausted.
618
+ */
619
+ async waitForStableVisible(
620
+ candidates: WebdriverIO.Element[],
621
+ timeout: number,
622
+ stableMs: number = 5000,
623
+ ): Promise<WebdriverIO.Element> {
624
+ const deadline = Date.now() + timeout;
625
+ while (true) {
626
+ const remaining = deadline - Date.now();
627
+ if (remaining <= 0) {
628
+ throw new Error(`waitForStableVisible: no stable candidate within ${timeout}ms`);
629
+ }
630
+ await Promise.race(candidates.map((el) => el.waitForDisplayed({ timeout: remaining })));
631
+ for (const el of candidates) {
632
+ if (await el.isDisplayed()) {
633
+ await browser.pause(stableMs);
634
+ if (await el.isDisplayed()) return el;
635
+ console.log(`waitForStableVisible: candidate "${el.selector}" disappeared — retrying race`);
636
+ break;
637
+ }
638
+ }
639
+ }
640
+ }
641
+
642
+ /**
643
+ * Retries an async step until it completes without throwing, up to `maxAttempts`
644
+ * times (default 3). The flow only moves ahead once the step is acknowledged
645
+ * (resolves) — so a missing application/build that is not yet populated by the
646
+ * backend gets re-attempted instead of failing the whole test on the first miss.
647
+ *
648
+ * Between failed attempts it pauses `delayMs`, then optionally runs `beforeRetry`
649
+ * (e.g. refresh / re-trigger a search) before the next attempt. The last error is
650
+ * only re-thrown after every attempt is exhausted.
651
+ */
652
+ async retryStep<T>(
653
+ action: () => Promise<T>,
654
+ options: {
655
+ maxAttempts?: number;
656
+ delayMs?: number;
657
+ label?: string;
658
+ beforeRetry?: (attempt: number) => Promise<void>;
659
+ } = {}
660
+ ): Promise<T> {
661
+ const { maxAttempts = 3, delayMs = 3000, label = "step", beforeRetry } = options;
662
+ let lastError: unknown;
663
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
664
+ try {
665
+ console.log(`[retry] Attempt ${attempt}/${maxAttempts} for: '${label}'`);
666
+ const result = await action();
667
+ console.log(`[retry] '${label}' acknowledged on attempt ${attempt}/${maxAttempts}`);
668
+ return result;
669
+ } catch (error) {
670
+ lastError = error;
671
+ console.log(`[retry] '${label}' failed on attempt ${attempt}/${maxAttempts}: ${error}`);
672
+ if (attempt < maxAttempts) {
673
+ await browser.pause(delayMs);
674
+ if (beforeRetry) {
675
+ await beforeRetry(attempt);
676
+ }
677
+ }
678
+ }
679
+ }
680
+ throw new Error(`[retry] '${label}' failed after ${maxAttempts} attempts. Last error: ${lastError}`);
681
+ }
682
+
683
+ /**
684
+ * Guards a step's precondition: the element that the PREVIOUS step is expected to
685
+ * have produced must be present before the current step runs. If it is not displayed
686
+ * within `timeout`, throws a descriptive error naming the missing output, so a step
687
+ * never acts on a page that silently reset or where the prior step never delivered
688
+ * its result. Returns true when the precondition holds (use the return value inside
689
+ * a retry's beforeRetry hook to decide whether re-confirmation is needed).
690
+ */
691
+ async ensurePrecondition(element: WebdriverIO.Element, label: string, timeout = 15000): Promise<boolean> {
692
+ try {
693
+ await element.waitForDisplayed({ timeout, interval: 500 });
694
+ console.log(`[precondition] OK — previous step output present: '${label}'`);
695
+ return true;
696
+ } catch (error) {
697
+ throw new Error(
698
+ `[precondition] FAILED — expected output from the previous step is not present: '${label}'. ` +
699
+ `The current step will not run. Underlying: ${error}`
700
+ );
701
+ }
702
+ }
703
+
704
+ /**
705
+ * Selects all text in a React controlled input and clears it via Backspace,
706
+ * triggering React's onChange. Use instead of clearValue() for React inputs.
707
+ */
708
+ async selectAllAndClear(element: WebdriverIO.Element) {
709
+ await element.waitForEnabled({ timeout: 40000, interval: 1000 });
710
+ await browser.execute((el: any) => el.select(), element);
711
+ await browser.keys("Backspace");
712
+ }
713
+
714
+ async clearByKeysAndType(element: WebdriverIO.Element, textToEnter: string) {
715
+ await element.waitForDisplayed({ timeout: 20000 });
716
+ await element.waitForEnabled({ timeout: 40000, interval: 1000 });
717
+ await element.click();
718
+ for (let attempt = 0; attempt < 10; attempt++) {
719
+ const current = await element.getValue();
720
+ if (!current) break;
721
+ await browser.keys("End");
722
+ await browser.keys(new Array(current.length + 2).fill("Backspace"));
723
+ }
724
+ if (textToEnter !== "") {
725
+ await element.addValue(textToEnter);
726
+ }
727
+ }
728
+
729
+ /**
730
+ * Moves the cursor to the absolute end of the currently focused contenteditable element
731
+ * (e.g. TinyMCE inline editor) using the DOM Selection API.
732
+ *
733
+ * Keyboard shortcuts (End, Ctrl+End, Cmd+Down+Cmd+Right) are unreliable in TinyMCE —
734
+ * the editor intercepts them or they only reach end of the current visual line when
735
+ * content wraps. Direct DOM manipulation bypasses all of that.
736
+ *
737
+ * Requires the target element to already have focus (call element.click() first).
738
+ * Runs in the current WebDriver frame context — call from inside the BeFree iframe
739
+ * after switchToIframe() so document refers to the iframe document.
740
+ */
741
+ async moveCursorToEndOfContentEditable(): Promise<void> {
742
+ await browser.execute(() => {
743
+ const el = document.activeElement as HTMLElement;
744
+ if (!el || !el.isContentEditable) return;
745
+ const range = document.createRange();
746
+ const sel = window.getSelection();
747
+ if (!sel) return;
748
+ range.selectNodeContents(el);
749
+ range.collapse(false); // collapse to end
750
+ sel.removeAllRanges();
751
+ sel.addRange(range);
752
+ });
753
+ }
754
+
755
+ }
756
+
757
+ export default new ElementUtils()