@capillarytech/cap-ui-utils 3.0.18 → 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.
- package/e2e/README.md +57 -0
- package/e2e/constants/common.ts +23 -0
- package/e2e/index.ts +30 -0
- package/e2e/pages/common/base.page.ts +11 -0
- package/e2e/pages/common/constant.ts +2 -0
- package/e2e/pages/common/login.page.ts +95 -0
- package/e2e/services/lockService.ts +210 -0
- package/e2e/services/locks/beeTemplateReady.signal +1 -0
- package/e2e/utils/antdVersionUtil.ts +190 -0
- package/e2e/utils/automationBypassUtil.ts +46 -0
- package/e2e/utils/debugModeUtil.ts +32 -0
- package/e2e/utils/deletionRegistry.ts +15 -0
- package/e2e/utils/elementUtil.ts +757 -0
- package/e2e/utils/expectUtil.ts +19 -0
- package/e2e/utils/featureFlagUtil.ts +30 -0
- package/e2e/utils/garudaDropdownUtil.ts +60 -0
- package/e2e/utils/htmlEditorUtil.ts +56 -0
- package/e2e/utils/logCollectorUtil.ts +52 -0
- package/e2e/utils/mockResponse.ts +24 -0
- package/e2e/utils/reportUploader.ts +172 -0
- package/e2e/utils/requestRecorderUtil.ts +178 -0
- package/e2e/utils/screenshotRecorderUtil.ts +174 -0
- package/e2e/utils/setupMock.ts +29 -0
- package/e2e/utils/unmatchedBracesUtil.ts +101 -0
- package/e2e/utils/uploaders/fileServiceUploader.ts +84 -0
- package/e2e/utils/uploaders/uploader.ts +20 -0
- package/e2e/utils/virtualListUtil.ts +115 -0
- package/index.js +1 -0
- package/package.json +15 -1
- package/utils/MFEModuleHeader.js +17 -0
- package/utils/useMFEModuleHeader.js +7 -3
|
@@ -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()
|