@cuppet/core 1.0.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,1003 @@
1
+ /**
2
+ * @module elementInteraction
3
+ * @typedef {import('puppeteer').Page} Page
4
+ * @typedef {import('puppeteer').Browser} Browser
5
+ */
6
+ const config = require('config');
7
+ const mime = require('mime');
8
+ const fs = require('fs');
9
+ const helper = require('./helperFunctions');
10
+
11
+ module.exports = {
12
+ /**
13
+ * Special handling in cases where you want a positive result if an element is missing.
14
+ * To be used in cases where element randomly shows/hides or the step is shared between profiles which have mixed
15
+ * support for that field.
16
+ * @param {Page} page
17
+ * @param selector
18
+ * @param skipFlag
19
+ * @returns {Promise<boolean>}
20
+ */
21
+ customWaitForSkippableElement: async function (page, selector, skipFlag) {
22
+ try {
23
+ await page.waitForSelector(selector, { visible: true });
24
+ } catch {
25
+ if (skipFlag) {
26
+ // Exit from the function as the step was marked for skipping
27
+ return true;
28
+ } else {
29
+ throw new Error(`Element with selector ${selector} not found!`);
30
+ }
31
+ }
32
+ },
33
+
34
+ /**
35
+ * Click on an element
36
+ * @param {Page} page
37
+ * @param selector
38
+ * @param skip - flag to skip the element if it is not present in the DOM
39
+ * @returns {Promise<void>}
40
+ */
41
+ click: async function (page, selector, skip = false) {
42
+ const skipped = await this.customWaitForSkippableElement(page, selector, skip);
43
+ if (skipped) {
44
+ return true;
45
+ }
46
+ const objectToCLick = await page.waitForSelector(selector, { visible: true });
47
+ const afterClickPromise = helper.afterClick(page);
48
+ try {
49
+ await objectToCLick.click({ delay: 150 });
50
+ } catch (error) {
51
+ throw new Error(`Could not click on element: ${selector}. Error: ${error}`);
52
+ }
53
+ // Resolve afterClick method
54
+ await afterClickPromise;
55
+ },
56
+
57
+ /**
58
+ * Click on multiple elements 1 by 1
59
+ * @param {Page} page
60
+ * @param selector
61
+ * @returns {Promise<void>}
62
+ */
63
+ clickAllElements: async function (page, selector) {
64
+ await page.waitForSelector(selector);
65
+ const elements = await page.$$(selector);
66
+ for (let element of elements) {
67
+ await new Promise(function (resolve) {
68
+ setTimeout(resolve, 200);
69
+ });
70
+ await element.click({ delay: 300 });
71
+ }
72
+ },
73
+
74
+ /**
75
+ * Press a single key
76
+ * @param {Page} page
77
+ * @param key - Name of key to press, such as ArrowLeft. See KeyInput for a list of all key names.
78
+ * @returns {Promise<void>}
79
+ */
80
+ pressKey: async function (page, key) {
81
+ try {
82
+ await page.keyboard.press(key, { delay: 100 });
83
+ } catch {
84
+ throw new Error(`Couldn't press key ${key} on the keyboard`);
85
+ }
86
+ },
87
+
88
+ /**
89
+ * Validate text in the page scripts
90
+ * @param {Page} page
91
+ * @param text
92
+ * @returns {Promise<void>}
93
+ */
94
+ validateTextInScript: async function (page, text) {
95
+ try {
96
+ await page.waitForSelector('xpath/' + `//script[contains(text(),'${text}')]`);
97
+ } catch {
98
+ throw new Error(`Could not find: ${text} in page scripts.`);
99
+ }
100
+ },
101
+
102
+ /**
103
+ * Validate that specific text can be found in the page structured data
104
+ * @param {Page} page
105
+ * @param text
106
+ * @returns {Promise<void>}
107
+ */
108
+ validateTextInSchemaOrg: async function (page, text) {
109
+ try {
110
+ await page.waitForSelector('script[type="application/ld+json"]');
111
+ await page.waitForSelector('xpath/' + `//script[contains(text(),'${text}')]`);
112
+ } catch {
113
+ throw new Error(`Could not find: ${text} in schema org.`);
114
+ }
115
+ },
116
+
117
+ /**
118
+ * Validate that specific text is missing in the structured data
119
+ * @param {Page} page
120
+ * @param text
121
+ * @returns {Promise<void>}
122
+ */
123
+ validateTextNotInSchemaOrg: async function (page, text) {
124
+ await page.waitForSelector('script[type="application/ld+json"]');
125
+ const isTextInSchema = await page.$(
126
+ 'xpath/' + `//script[@type="application/ld+json"][contains(text(),'${text}')]`
127
+ );
128
+ if (isTextInSchema) {
129
+ throw new Error(`${text} can be found in the schema org.`);
130
+ }
131
+ },
132
+
133
+ /**
134
+ * Click on element by its text value
135
+ * @param {Page} page
136
+ * @param text
137
+ * @returns {Promise<void>}
138
+ */
139
+ clickByText: async function (page, text) {
140
+ const objectToClick = await page.waitForSelector('xpath/' + `//body//*[text()[contains(.,'${text}')]]`);
141
+ const afterClickPromise = helper.afterClick(page);
142
+ try {
143
+ await objectToClick.click();
144
+ } catch {
145
+ throw new Error(`Could not click on element with text ${text}`);
146
+ }
147
+ // Resolve afterClick method
148
+ await afterClickPromise;
149
+ },
150
+
151
+ /**
152
+ * Follow link by its name(text value). To be used on target="_self"
153
+ * @param {Page} page
154
+ * @param text
155
+ * @returns {Promise<void>}
156
+ */
157
+ followLink: async function (page, text) {
158
+ const objectToClick = await page.waitForSelector('xpath/' + `//a[contains(text(), '${text}')]`);
159
+ const navigationPromise = page.waitForNavigation();
160
+ try {
161
+ await objectToClick.click();
162
+ await navigationPromise;
163
+ } catch {
164
+ throw new Error(`Could not click on the element with text: ${text}`);
165
+ }
166
+ },
167
+
168
+ /**
169
+ * Click on the text of a link and expect it to open in a new tab. target="_blank"
170
+ * @param {Browser} browser
171
+ * @param {Page} page
172
+ * @param value - either text or css selector
173
+ * @param xpath - flag, whether to use xpath or not
174
+ * @returns {Promise<Object>}
175
+ */
176
+ clickLinkOpenNewTab: async function (browser, page, value, xpath = true) {
177
+ let objectToClick;
178
+ if (xpath) {
179
+ const result = await helper.getMultilingualString(value);
180
+ objectToClick = await page.waitForSelector('xpath/' + `//body//*[text()[contains(.,'${result}')]]`);
181
+ } else {
182
+ objectToClick = await page.waitForSelector(value);
183
+ }
184
+
185
+ try {
186
+ await objectToClick.click();
187
+ } catch (error) {
188
+ throw new Error(`Could not click on the element. Reason: ${error}`);
189
+ }
190
+ // This is made for the standard case of clicking on a link of the first tab and opening second.
191
+ // If you are working on more than two tabs, please use switchToTab() method.
192
+ return await helper.switchToTab(browser, 2);
193
+ },
194
+
195
+ /**
196
+ * Click on element by css selector and follow the popup window
197
+ * @param {Page} page
198
+ * @param selector
199
+ * @returns {Promise<Object>}
200
+ */
201
+ clickElementOpenPopup: async function (page, selector) {
202
+ const objectToClick = await page.waitForSelector(selector, { visible: true });
203
+ // Set up a listener for the 'popup' event
204
+ const popupPromise = new Promise((resolve) => page.once('popup', resolve));
205
+ try {
206
+ await objectToClick.click();
207
+ } catch {
208
+ throw new Error(`Could not click on element with selector ${selector}`);
209
+ }
210
+ // Return the popup as a new page object
211
+ return popupPromise;
212
+ },
213
+
214
+ /**
215
+ * Find link by text and validate it's href value
216
+ * @param {Page} page
217
+ * @param text
218
+ * @param href
219
+ * @returns {Promise<void>}
220
+ */
221
+ validateHrefByText: async function (page, text, href) {
222
+ const objectToSelect = await page.waitForSelector('xpath/' + `//a[contains(text(), '${text}')]`);
223
+ const hrefElement = await (await objectToSelect.getProperty('href')).jsonValue();
224
+ if (hrefElement !== href) {
225
+ throw new Error(`The href of the link is ${hrefElement} and it is different from the expected ${href}!`);
226
+ }
227
+ },
228
+
229
+ /**
230
+ * Validate that element is rendered and visible by its css selector.
231
+ * Mind that hidden elements will not show (DOM existence is not enough for that step)
232
+ * @param {Page} page
233
+ * @param selector
234
+ * @param {boolean} isVisible - set to false for validating dom existence only
235
+ * @param {int} time
236
+ * @returns {Promise<void>}
237
+ */
238
+ seeElement: async function (page, selector, isVisible = true, time = 3000) {
239
+ const options = {
240
+ visible: isVisible, // Wait for the element to be visible
241
+ timeout: time, // Maximum time to wait in milliseconds
242
+ };
243
+ try {
244
+ await page.waitForSelector(selector, options);
245
+ } catch {
246
+ throw new Error(`There is no element with selector: ${selector}!`);
247
+ }
248
+ },
249
+
250
+ /**
251
+ * Validate specific link attribute value. Find the link using its href value.
252
+ * @param {Page} page - current puppeteer tab
253
+ * @param href - link href value
254
+ * @param attribute - attribute you search for
255
+ * @param value - the expected value of that attribute
256
+ * @param skip - check method customWaitForSkippableElement() for more info
257
+ * @returns {Promise<boolean>}
258
+ */
259
+ validateValueOfLinkAttributeByHref: async function (page, href, attribute, value, skip = false) {
260
+ const attrValue = await page.$eval(
261
+ `a[href="${href}"]`,
262
+ (el, attribute) => el.getAttribute(attribute),
263
+ attribute
264
+ );
265
+ if (!attrValue && skip === true) {
266
+ // Exit successfully if there is no value and the step is marked to be skipped
267
+ return true;
268
+ }
269
+ if (value !== attrValue) {
270
+ throw new Error(`The provided link "${href}" does not have an attribute with value: ${value}.`);
271
+ }
272
+ },
273
+
274
+ /**
275
+ * Validate the value of certain attribute for a generic element by using its css selector to locate it.
276
+ * @param {Page} page
277
+ * @param selector
278
+ * @param attribute
279
+ * @param value
280
+ * @param skip
281
+ * @returns {Promise<boolean>}
282
+ */
283
+ validateElementWithSelectorHasAttributeWithValue: async function (page, selector, attribute, value, skip = false) {
284
+ const skipped = await this.customWaitForSkippableElement(page, selector, skip);
285
+ if (skipped) {
286
+ return true;
287
+ }
288
+ const attrValue = await page.$eval(selector, (el, attribute) => el.getAttribute(attribute), attribute);
289
+ if (value !== attrValue) {
290
+ throw new Error(
291
+ `The provided element with selector "${selector}" does not have an attribute with value: ${value}.`
292
+ );
293
+ }
294
+ },
295
+
296
+ /**
297
+ * Same as the method above validateElementWithSelectorHasAttributeWithValue(), but using
298
+ * the text of the element to locate it.
299
+ * @param {Page} page
300
+ * @param text
301
+ * @param attribute
302
+ * @param value
303
+ * @returns {Promise<void>}
304
+ */
305
+ validateValueOfElementAttributeByText: async function (page, text, attribute, value) {
306
+ const result = await helper.getMultilingualString(text);
307
+ const selector = 'xpath/' + `//body//*[text()[contains(.,'${result}')]]`;
308
+ await page.waitForSelector(selector);
309
+ const attrValue = await page.$eval(selector, (el, attribute) => el.getAttribute(attribute), attribute);
310
+ if (value !== attrValue) {
311
+ throw new Error(`The provided text "${result}" doesn't match element which attribute has value: ${value}.`);
312
+ }
313
+ },
314
+
315
+ /**
316
+ * Element should not exist in the page DOM.
317
+ * @param {Page} page
318
+ * @param selector
319
+ * @param time
320
+ * @returns {Promise<void>}
321
+ */
322
+ notSeeElement: async function (page, selector, time = 5000) {
323
+ const options = {
324
+ hidden: true,
325
+ timeout: time, // Maximum time to wait in milliseconds (default: 30000)
326
+ };
327
+ let isElementInPage = false;
328
+ try {
329
+ isElementInPage = await page.waitForSelector(selector, options);
330
+ } catch {
331
+ throw new Error('Element is visible!');
332
+ }
333
+ if (isElementInPage) {
334
+ throw new Error(`${selector} is hidden but can be found in the page source!`);
335
+ }
336
+ },
337
+
338
+ /**
339
+ * Return the iframe to be used as a page object.
340
+ * @param {Page} page
341
+ * @param selector
342
+ * @returns {Promise<Frame>}
343
+ */
344
+ getFrameBySelector: async function (page, selector) {
345
+ try {
346
+ await page.waitForSelector(selector);
347
+ const frameHandle = await page.$(selector);
348
+ return frameHandle.contentFrame();
349
+ } catch {
350
+ throw new Error(`iFrame with css selector: ${selector} cannot be found!`);
351
+ }
352
+ },
353
+
354
+ /**
355
+ * Validate visibility of text by using xpath to locate it.
356
+ * @param {Page} page
357
+ * @param text
358
+ * @param time
359
+ * @returns {Promise<void>}
360
+ */
361
+ seeTextByXpath: async function (page, text, time = 6000) {
362
+ let result = await helper.getMultilingualString(text);
363
+ const options = {
364
+ visible: true, // Wait for the element to be visible (default: false)
365
+ timeout: time, // Maximum time to wait in milliseconds (default: 30000)
366
+ };
367
+ if (time > 6000 && !page['_name']) {
368
+ await new Promise(function (resolve) {
369
+ setTimeout(resolve, 500);
370
+ });
371
+ }
372
+ try {
373
+ await page.waitForSelector('xpath/' + `//body//*[text()[contains(.,"${result}")]]`, options);
374
+ } catch (error) {
375
+ throw new Error(`Could not find text : ${result}. The error thrown is: ${error}`);
376
+ }
377
+ },
378
+
379
+ /**
380
+ * Validate text existence in DOM using element textContent value.
381
+ * (can't validate whether you can see it with your eyes or not)
382
+ * @param {Page} page
383
+ * @param selector
384
+ * @param text
385
+ * @returns {Promise<void>}
386
+ */
387
+ seeTextByElementHandle: async function (page, selector, text) {
388
+ const result = await helper.getMultilingualString(text);
389
+ await page.waitForSelector(selector);
390
+ let textContent = await page.$eval(selector, (element) => element.textContent.trim());
391
+ if (!textContent) {
392
+ textContent = await page.$eval(selector, (element) => element.value.trim());
393
+ }
394
+ if (textContent !== result) {
395
+ throw new Error(`Expected ${result} text, but found ${textContent} instead.`);
396
+ }
397
+ },
398
+
399
+ /**
400
+ * Validate that text is visible in specific region (another element).
401
+ * To be used when multiple renders of the same text are shown on the page.
402
+ * @param {Page} page
403
+ * @param text
404
+ * @param region
405
+ * @returns {Promise<void>}
406
+ */
407
+ seeTextInRegion: async function (page, text, region) {
408
+ const regionClass = await helper.getRegion(page, region);
409
+ const result = await helper.getMultilingualString(text);
410
+ try {
411
+ await page.waitForSelector(
412
+ 'xpath/' + `//*[contains(@class,'${regionClass}') and .//text()[contains(.,"${result}")]]`
413
+ );
414
+ } catch {
415
+ throw new Error(`Cannot find ${result} in ${regionClass}!`);
416
+ }
417
+ },
418
+
419
+ /**
420
+ * Hover element based on text content (useful for text inside spans, paragraphs etc. like menu links)
421
+ * @param {Page} page
422
+ * @param text
423
+ * @param region
424
+ * @returns {Promise<void>}
425
+ */
426
+ hoverTextInRegion: async function (page, text, region) {
427
+ const regionClass = await helper.getRegion(page, region);
428
+ const result = await helper.getMultilingualString(text);
429
+ const selector = 'xpath/' + `//*[@class='${regionClass}']//*[text()='${result}']`;
430
+ try {
431
+ const element = await page.waitForSelector(selector);
432
+ const parentElementHandle = await page.evaluateHandle((el) => el.parentElement, element);
433
+ await parentElementHandle.hover();
434
+ } catch (error) {
435
+ throw new Error(error);
436
+ }
437
+ },
438
+
439
+ /**
440
+ * Validate that the text is not rendered on the page.
441
+ * @param {Page} page
442
+ * @param text
443
+ * @returns {Promise<void>}
444
+ */
445
+ notSeeText: async function (page, text) {
446
+ let result = await helper.getMultilingualString(text);
447
+ const isTextInDom = await page.$('xpath/' + `//*[text()[contains(.,'${result}')]]`);
448
+ // isVisible() is used for the cases where the text is in the DOM, but not visible
449
+ // If you need to NOT have it in the DOM - use notSeeElement() or extend this step with flag
450
+ const visibility = await isTextInDom?.isVisible();
451
+ if (visibility) {
452
+ throw new Error(`${text} can be found in the page source.`);
453
+ }
454
+ },
455
+
456
+ /**
457
+ * Validate text value of certain element (input, p, span etc.)
458
+ * @param {Page} page
459
+ * @param text
460
+ * @param selector
461
+ * @returns {Promise<void>}
462
+ */
463
+ validateTextInField: async function (page, text, selector) {
464
+ let result = await helper.getMultilingualString(text);
465
+ let value = '';
466
+ await page.waitForSelector(selector);
467
+ try {
468
+ const el = await page.$(selector);
469
+ const elementType = await page.evaluate((el) => el.tagName, el);
470
+ if (elementType.toLowerCase() === 'input' || elementType.toLowerCase() === 'textarea') {
471
+ value = await (await page.evaluateHandle((el) => el.value, el)).jsonValue();
472
+ } else {
473
+ value = await (await page.evaluateHandle((el) => el.innerText, el)).jsonValue();
474
+ }
475
+ } catch (error) {
476
+ throw new Error(error);
477
+ }
478
+ if (value !== result) {
479
+ throw new Error(`Value of element ${value} does not match the text ${result}`);
480
+ }
481
+ },
482
+
483
+ /**
484
+ * Validate that text is actually shown/hidden on closing/opening of an accordion
485
+ * @param {Page} page
486
+ * @param cssSelector
487
+ * @param text
488
+ * @param isVisible
489
+ * @returns {Promise<void>}
490
+ */
491
+ textVisibilityInAccordion: async function (page, cssSelector, text, isVisible) {
492
+ let result = await helper.getMultilingualString(text);
493
+ const el = await page.$(cssSelector);
494
+ if (el) {
495
+ const isShown = await (await page.evaluateHandle((el) => el.clientHeight, el)).jsonValue();
496
+ if (Boolean(isShown) !== isVisible) {
497
+ throw new Error('Element visibility does not match the requirement!');
498
+ }
499
+ if (isShown) {
500
+ const textValue = await (await page.evaluateHandle((el) => el.textContent.trim(), el)).jsonValue();
501
+ if (isVisible && textValue !== result) {
502
+ throw new Error(`Element text: ${textValue} does not match the expected: ${result}!`);
503
+ } else if (!isVisible && textValue === result) {
504
+ throw new Error(`Element text: ${textValue} is visible but it should not be!`);
505
+ }
506
+ }
507
+ } else if (isVisible) {
508
+ throw new Error(`The element with ${cssSelector} is missing from the DOM tree.`);
509
+ }
510
+ },
511
+
512
+ /**
513
+ * Validate that text disappears in certain time from the page.
514
+ * Can be used for toasts, notifications etc.
515
+ * @param {Page} page
516
+ * @param text
517
+ * @param time
518
+ * @returns {Promise<void>}
519
+ */
520
+ disappearText: async function (page, text, time) {
521
+ let result = await helper.getMultilingualString(text);
522
+ const options = {
523
+ visible: true, // Wait for the element to be visible (default: false)
524
+ timeout: 250, // 250ms and for that reason time is multiplied by 4 to add up to a full second.
525
+ };
526
+ for (let i = 0; i < time * 4; i++) {
527
+ try {
528
+ await page.waitForSelector('xpath/' + `//*[text()[contains(.,'${result}')]]`, options);
529
+ } catch {
530
+ console.log(`Element disappeared in ${time * 4}.`);
531
+ break;
532
+ }
533
+ }
534
+ },
535
+
536
+ /**
537
+ * Click on an element by its text in a certain region.
538
+ * To be used when there are multiple occurrences of that text.
539
+ * @param {Page} page
540
+ * @param text
541
+ * @param region
542
+ * @returns {Promise<void>}
543
+ */
544
+ clickTextInRegion: async function (page, text, region) {
545
+ const regionClass = await helper.getRegion(page, region);
546
+ const result = await helper.getMultilingualString(text);
547
+ await page.waitForSelector('xpath/' + `//*[@class='${regionClass}']`);
548
+ const elements =
549
+ (await page.$$('xpath/' + `//*[@class='${regionClass}']//*[text()='${result}']`)) ||
550
+ (await page.$$('xpath/' + `//*[@class='${regionClass}']//*[contains(text(),'${result}')]`));
551
+
552
+ if (!elements?.[0]) {
553
+ throw new Error('Element not found!');
554
+ }
555
+
556
+ const afterClickPromise = helper.afterClick(page);
557
+ await elements[0].click();
558
+ await afterClickPromise;
559
+ },
560
+
561
+ /**
562
+ * Standard file upload into normal HTML file upload field
563
+ * @param {Page} page
564
+ * @param fileName
565
+ * @param selector
566
+ * @returns {Promise<void>}
567
+ */
568
+ uploadFile: async function (page, fileName, selector) {
569
+ await page.waitForSelector(selector);
570
+ const element = await page.$(selector);
571
+ const filePath = config.has('filePath') ? config.get('filePath') : 'files/';
572
+ await element.uploadFile(filePath + fileName);
573
+ // Additional wait as the promise for file upload not always resolve on time when no slowMo is added.
574
+ await new Promise((resolve) => setTimeout(resolve, 500));
575
+ },
576
+
577
+ /**
578
+ * Drupal and dropzone specific file upload method.
579
+ * @param {Page} page
580
+ * @param fileName
581
+ * @param selector
582
+ * @returns {Promise<void>}
583
+ */
584
+ uploadToDropzone: async function (page, fileName, selector) {
585
+ await page.waitForSelector(selector);
586
+ try {
587
+ const element = await page.$(selector);
588
+ const realSelector = await (await element.getProperty('id')).jsonValue();
589
+ const filePath = config.has('filePath') ? config.get('filePath') : 'files/';
590
+ const fullPath = filePath + fileName;
591
+ const mimeType = mime.getType(fullPath);
592
+ const contents = fs.readFileSync(fullPath, { encoding: 'base64' });
593
+ const jsCode = `
594
+ var url = "data:${mimeType};base64,${contents}"
595
+ var file;
596
+ fetch(url)
597
+ .then(response => response.blob())
598
+ .then(file => {
599
+ file.name = "${fileName}";
600
+ drupalSettings.dropzonejs.instances['${realSelector}'].instance.addFile(file)});`;
601
+ await page.evaluate(jsCode);
602
+ } catch (error) {
603
+ throw new Error(error);
604
+ }
605
+ },
606
+
607
+ /**
608
+ * Put value in a field. It directly places the text like Ctrl+V(Paste) will do it.
609
+ * @param {Page} page
610
+ * @param selector
611
+ * @param data
612
+ * @param skip
613
+ * @returns {Promise<boolean>}
614
+ */
615
+ fillField: async function (page, selector, data, skip = false) {
616
+ let result = await helper.getMultilingualString(data);
617
+ const skipped = await this.customWaitForSkippableElement(page, selector, skip);
618
+ if (skipped) {
619
+ return true;
620
+ }
621
+ try {
622
+ await page.$eval(selector, (el, name) => (el.value = name), result);
623
+ await new Promise(function (resolve) {
624
+ setTimeout(resolve, 500);
625
+ });
626
+ } catch (error) {
627
+ throw new Error(error);
628
+ }
629
+ },
630
+
631
+ /**
632
+ * Simulates typing char by char in a field. Useful for fields which have some auto suggest/autocomplete logic behind it.
633
+ * @param {Page} page
634
+ * @param selector
635
+ * @param text
636
+ * @param skip
637
+ * @returns {Promise<boolean>}
638
+ */
639
+ typeInField: async function (page, selector, text, skip = false) {
640
+ let result = await helper.getMultilingualString(text);
641
+ const skipped = await this.customWaitForSkippableElement(page, selector, skip);
642
+ if (skipped) {
643
+ return true;
644
+ }
645
+ const el = await page.$(selector);
646
+ const elementType = await page.evaluate((el) => el.tagName, el);
647
+ if (elementType.toLowerCase() === 'input' || elementType.toLowerCase() === 'textarea') {
648
+ await page.$eval(selector, (input) => (input.value = ''));
649
+ await new Promise(function (resolve) {
650
+ setTimeout(resolve, 150);
651
+ });
652
+ try {
653
+ await page.type(selector, result, { delay: 250 });
654
+ } catch (error) {
655
+ throw new Error(`Cannot type into field due to ${error}`);
656
+ }
657
+ }
658
+ },
659
+
660
+ /**
661
+ * Check or uncheck a checkbox. Do nothing if the direction matches the current state.
662
+ * @param {Page} page
663
+ * @param selector
664
+ * @param action
665
+ * @param skip
666
+ * @returns {Promise<boolean>}
667
+ */
668
+ useCheckbox: async function (page, selector, action, skip = false) {
669
+ const skipped = await this.customWaitForSkippableElement(page, selector, skip);
670
+ if (skipped) {
671
+ return true;
672
+ }
673
+ const element = await page.$(selector);
674
+ await new Promise((resolve) => setTimeout(resolve, 200));
675
+ const checked = await (await element.getProperty('checked')).jsonValue();
676
+ if ((!checked && action === 'select') || (checked && action === 'deselect')) {
677
+ await element.click();
678
+ } else if ((checked && action === 'select') || (!checked && action === 'deselect')) {
679
+ // Exit successfully when the requested action matches the current state
680
+ return true;
681
+ } else {
682
+ throw new Error(`Action: ${action} does not fit the current state of the checkbox!`);
683
+ }
684
+ },
685
+
686
+ /**
687
+ * Write into CkEditor5 using its API.
688
+ * @param {Page} page
689
+ * @param selector
690
+ * @param text
691
+ * @returns {Promise<*>}
692
+ */
693
+ writeInCkEditor5: async function (page, selector, text) {
694
+ const textValue = text === 'noText' ? '' : text;
695
+ const options = { hidden: true };
696
+ await page.waitForSelector(selector, options);
697
+ try {
698
+ const elementId = await page.$eval(selector, (el) => el.getAttribute('data-ckeditor5-id'));
699
+ let jsCode = `
700
+ (function () {
701
+ let textEditor = Drupal.CKEditor5Instances.get('${elementId}');
702
+ textEditor.setData('');
703
+ const docFrag = textEditor.model.change(writer => {
704
+ const p1 = writer.createElement('paragraph');
705
+ const docFrag = writer.createDocumentFragment();
706
+ writer.append(p1, docFrag);
707
+ writer.insertText('${textValue}', p1);
708
+ return docFrag;
709
+ }
710
+ );
711
+ textEditor.model.insertContent(docFrag);
712
+ })();
713
+ `;
714
+ return page.evaluate(jsCode);
715
+ } catch (error) {
716
+ throw new Error(`Cannot write into CkEditor5 field due to: ${error}!`);
717
+ }
718
+ },
719
+
720
+ /**
721
+ * Selects option by its html value.
722
+ * The method supports the skip property.
723
+ * @param {Page} page
724
+ * @param selector
725
+ * @param value
726
+ * @param skip
727
+ * @returns {Promise<boolean|void>}
728
+ */
729
+ selectOptionByValue: async function (page, selector, value, skip = false) {
730
+ const skipped = await this.customWaitForSkippableElement(page, selector, skip);
731
+ if (skipped) {
732
+ return true;
733
+ }
734
+ const selectedValue = await page.select(selector, value);
735
+ if (selectedValue.length === 0) {
736
+ throw new Error(`The option ${value} is either missing or not selected!`);
737
+ }
738
+ },
739
+
740
+ /**
741
+ * Selects option by its text value
742
+ * @param {Page} page
743
+ * @param selector
744
+ * @param text
745
+ * @returns {Promise<void>}
746
+ */
747
+ selectOptionByText: async function (page, selector, text) {
748
+ let result = await helper.getMultilingualString(text);
749
+ await page.waitForSelector(selector);
750
+ const objectToSelect = await page.$('xpath/' + `//body//*[contains(text(), '${result}')]`);
751
+ if (objectToSelect) {
752
+ const value = await (await objectToSelect.getProperty('value')).jsonValue();
753
+ await page.select(selector, value);
754
+ } else {
755
+ throw new Error(`Could not find option with text: ${result}`);
756
+ }
757
+ },
758
+
759
+ /**
760
+ * Selects the first autocomplete option using the keyboard keys
761
+ * from a dropdown with auto-suggest.
762
+ * @param {Page} page
763
+ * @param text
764
+ * @param selector
765
+ * @returns {Promise<void>}
766
+ */
767
+ selectOptionFirstAutocomplete: async function (page, text, selector) {
768
+ await page.waitForSelector(selector);
769
+ await page.type(selector, text, { delay: 150 });
770
+ await new Promise(function (resolve) {
771
+ setTimeout(resolve, 1000);
772
+ });
773
+ const el = await page.$(selector);
774
+ await el.focus();
775
+ await page.keyboard.press('ArrowDown', { delay: 100 });
776
+ await helper.waitForAjax(page);
777
+ await new Promise(function (resolve) {
778
+ setTimeout(resolve, 1000);
779
+ });
780
+ await page.keyboard.press('Enter', { delay: 100 });
781
+ },
782
+
783
+ /**
784
+ * Selects option from a dropdown using chosen JS field.
785
+ * @param {Page} page
786
+ * @param string
787
+ * @param selector
788
+ * @returns {Promise<void>}
789
+ */
790
+ selectOptionFromChosen: async function (page, string, selector) {
791
+ await page.waitForSelector(selector);
792
+ const options = await page.$eval(selector, (select) => {
793
+ return Array.from(select.options).map((option) => ({
794
+ value: option.value,
795
+ text: option.text,
796
+ }));
797
+ });
798
+ const result = options.find(({ text }) => text === string);
799
+ const jsCode = `
800
+ jQuery('${selector}').val("${result.value}");
801
+ jQuery('${selector}').trigger("chosen:updated");
802
+ jQuery('${selector}').trigger("change");
803
+ `;
804
+ await page.evaluate(jsCode);
805
+ },
806
+
807
+ /**
808
+ * Method to verify if a dropdown text values are in alphabetical order
809
+ *
810
+ * @param {Object} page
811
+ * @param {string} selector
812
+ * @param {boolean} flag
813
+ * @returns {Promise<void>}
814
+ */
815
+ iCheckIfDropdownOptionsAreInAlphabeticalOrder: async function (page, selector, flag) {
816
+ await page.waitForSelector(selector);
817
+ const options = await page.$eval(selector, (select) => {
818
+ return Array.from(select.options).map((option) => ({
819
+ value: option.value,
820
+ text: option.text,
821
+ }));
822
+ });
823
+
824
+ // Remove fist element if it's none (can be extended for other placeholders)
825
+ if (options[0].value === '_none') {
826
+ options.shift();
827
+ }
828
+
829
+ const isArraySorted = helper.isArraySorted(options, 'text');
830
+
831
+ if (Boolean(isArraySorted) !== flag) {
832
+ throw new Error(`Dropdown options are not sorted as expected`);
833
+ }
834
+ },
835
+
836
+ /**
837
+ * Method to verify if the checkbox text values are in alphabetical order
838
+ *
839
+ * @param {Object} page
840
+ * @param {string} selector
841
+ * @param {boolean} flag
842
+ * @returns {Promise<void>}
843
+ */
844
+ iCheckIfCheckboxOptionsAreInAlphabeticalOrder: async function (page, selector, flag) {
845
+ await page.waitForSelector(selector);
846
+ const elements = await page.$$(selector);
847
+
848
+ const texts = await Promise.all(
849
+ elements.map((element) =>
850
+ element.getProperty('textContent').then((propertyHandle) => propertyHandle.jsonValue())
851
+ )
852
+ );
853
+
854
+ const isArraySorted = helper.isArraySorted(texts, 0);
855
+
856
+ if (Boolean(isArraySorted) !== flag) {
857
+ throw new Error(`The checkboxes are not sorted as expected`);
858
+ }
859
+ },
860
+
861
+ /**
862
+ * Sets date in a https://flatpickr.js.org/ based field.
863
+ * @param {Page} page
864
+ * @param selector
865
+ * @param value
866
+ * @returns {Promise<void>}
867
+ */
868
+ setDateFlatpickr: async function (page, selector, value) {
869
+ await page.waitForSelector(selector);
870
+ try {
871
+ await page.$eval(selector, (el, date) => el._flatpickr.setDate(`${date}`, true), value);
872
+ } catch (error) {
873
+ throw new Error(`Cannot set date due to ${error}!`);
874
+ }
875
+ },
876
+
877
+ /**
878
+ * Scrolls element to the top of the page using cssSelector
879
+ * @param {Page} page
880
+ * @param cssSelector
881
+ * @returns {Promise<void>}
882
+ */
883
+ scrollElementToTop: async function (page, cssSelector) {
884
+ await page.waitForSelector(cssSelector);
885
+ try {
886
+ const el = await page.$(cssSelector);
887
+ await page.evaluate((el) => el.scrollIntoView(true), el);
888
+ } catch (error) {
889
+ throw new Error(error);
890
+ }
891
+ },
892
+
893
+ /**
894
+ * Sets value into codemirror field
895
+ * @param {Page} page
896
+ * @param cssSelector
897
+ * @param value
898
+ * @returns {Promise<void>}
899
+ */
900
+ setValueInCodeMirrorField: async function (page, cssSelector, value) {
901
+ let result = await helper.getMultilingualString(value);
902
+ await page.waitForSelector(cssSelector);
903
+ try {
904
+ const jsCode = `
905
+ (function () {
906
+ const textArea = document.querySelector('${cssSelector}');
907
+ let editor = CodeMirror.fromTextArea(textArea);
908
+ editor.getDoc().setValue("${result}");
909
+ })();
910
+ `;
911
+ await page.evaluate(jsCode);
912
+ } catch (error) {
913
+ throw new Error(error);
914
+ }
915
+ },
916
+
917
+ /**
918
+ * Sets value in a datetime picker field and triggers the change event
919
+ * @param {Page} page
920
+ * @param {string} selector - CSS selector for the datetime picker input
921
+ * @param {string} value - Datetime value in format 'YYYY/MM/DD HH:mm'
922
+ * @returns {Promise<void>}
923
+ */
924
+ setDateTimePickerValue: async function (page, selector, value) {
925
+ await page.waitForSelector(selector);
926
+ try {
927
+ const jsCode = `
928
+ (function () {
929
+ const input = document.querySelector('${selector}');
930
+ input.value = '${value}';
931
+ input.dispatchEvent(new Event('change', { bubbles: true }));
932
+ })();
933
+ `;
934
+ await page.evaluate(jsCode);
935
+ } catch (error) {
936
+ throw new Error(`Cannot set datetime picker value due to: ${error}`);
937
+ }
938
+ },
939
+
940
+ /**
941
+ * Helper function to format date with specified format and relative range
942
+ * @param {string} format - Date format (e.g. 'd-m-Y H:i', 'Y-M-d H')
943
+ * @param {string} range - Relative date range (e.g. '+2 days', '+5 days')
944
+ * @returns {string} - Formatted date string
945
+ * @example
946
+ * formatDateWithRange('d-m-Y H:i', '+2 days') // returns "15-03-2024 14:30"
947
+ * formatDateWithRange('Y-M-d H', '+5 days') // returns "2024-03-18 14"
948
+ */
949
+ formatDateWithRange: function (format, range) {
950
+ // Parse relative date
951
+ const match = range.match(/^\+(\d+)\s*(day|days|hour|hours|minute|minutes)$/i);
952
+ if (!match) {
953
+ throw new Error(`Invalid range format. Expected format: '+N days/hours/minutes'`);
954
+ }
955
+
956
+ const amount = parseInt(match[1]);
957
+ const unit = match[2].toLowerCase();
958
+
959
+ const date = new Date();
960
+
961
+ switch (unit) {
962
+ case 'day':
963
+ case 'days':
964
+ date.setDate(date.getDate() + amount);
965
+ break;
966
+ case 'hour':
967
+ case 'hours':
968
+ date.setHours(date.getHours() + amount);
969
+ break;
970
+ case 'minute':
971
+ case 'minutes':
972
+ date.setMinutes(date.getMinutes() + amount);
973
+ break;
974
+ }
975
+
976
+ // Format the date according to the specified format
977
+ const formatMap = {
978
+ d: String(date.getDate()).padStart(2, '0'),
979
+ m: String(date.getMonth() + 1).padStart(2, '0'),
980
+ Y: date.getFullYear(),
981
+ H: String(date.getHours()).padStart(2, '0'),
982
+ i: String(date.getMinutes()).padStart(2, '0'),
983
+ M: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'][date.getMonth()],
984
+ };
985
+
986
+ return format.replace(/[dmyYHMi]/g, (match) => formatMap[match] || match);
987
+ },
988
+
989
+ /**
990
+ * Set value in datetime picker with specified format and relative range
991
+ * @param {Page} page
992
+ * @param {string} selector - CSS selector for the datetime picker
993
+ * @param {string} format - Date format (e.g. 'd-m-Y H:i', 'Y-M-d H')
994
+ * @param {string} range - Relative date range (e.g. '+2 days', '+5 days')
995
+ * @returns {Promise<void>}
996
+ * @example
997
+ * await setDateTimePickerWithFormat(page, '#start_date', 'd-m-Y H:i', '+2 days')
998
+ */
999
+ setDateTimePickerWithFormat: async function (page, selector, format, range) {
1000
+ const formattedDate = this.formatDateWithRange(format, range);
1001
+ await this.setDateTimePickerValue(page, selector, formattedDate);
1002
+ },
1003
+ };