PluginProbe
Hello Plus / trunk
Hello Plus vtrunk
trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.1.0 1.1.1 1.1.2 1.1.3 1.2.0 1.2.1 1.3.0 1.4.0 1.4.1 1.5.0 1.5.1 1.5.2 1.5.3 1.6.0 1.6.1 1.6.2 1.7.0 1.7.1 1.7.2 1.7.3 All 30 releases
hello-plus / tests / playwright / pages / editor-page.ts

editor-page.ts in Hello Plus trunk, at tests/playwright/pages/editor-page.ts

1,577 lines 52.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import { readFile } from 'fs/promises';
2 import { addElement, getElementSelector } from '../assets/elements-utils';
3 import { expect, type Page, type Frame, type TestInfo, type ElementHandle, Locator } from '@playwright/test';
4 import BasePage from './base-page';
5 import EditorSelectors from '../selectors/editor-selectors';
6 import _path, { resolve as pathResolve } from 'path';
7 // eslint-disable-next-line import/no-extraneous-dependencies
8 import { getComparator } from 'playwright-core/lib/utils';
9 import { $eType, Device, WindowType, BackboneType, ElementorType, GapControl, ContainerType, ContainerPreset } from '../types/types';
10 import TopBarSelectors, { TopBarSelector } from '../selectors/top-bar-selectors';
11 import Breakpoints from '../assets/breakpoints';
12 import { timeouts } from '../config/timeouts';
13
14 let $e: $eType;
15 let elementor: ElementorType;
16 let Backbone: BackboneType;
17 let window: WindowType;
18
19 export default class EditorPage extends BasePage {
20 readonly previewFrame: Frame;
21 postId: number;
22 isPanelLoaded = false;
23
24 /**
25 * Create an Elementor editor page.
26 *
27 * @param {Page} page - Playwright page instance.
28 * @param {TestInfo} testInfo - Test information.
29 * @param {number} cleanPostId - Optional. Post ID.
30 */
31 constructor( page: Page, testInfo: TestInfo, cleanPostId: null | number = null ) {
32 super( page, testInfo );
33 this.previewFrame = this.getPreviewFrame();
34 this.postId = cleanPostId;
35 }
36
37 /**
38 * Open a specific post in the elementor editor.
39 *
40 * @param {number|string} id - Optional. Post ID. Default is the ID of the current post.
41 *
42 * @return {Promise<void>}
43 */
44 async gotoPostId( id: number|string = this.postId ): Promise<void> {
45 await this.page.goto( `wp-admin/post.php?post=${ id }&action=elementor` );
46 await this.page.waitForLoadState( 'load' );
47 await this.waitForPanelToLoad();
48 }
49
50 /**
51 * Update image dates in the template data.
52 *
53 * @param {JSON} templateData - Template data.
54 *
55 * @return {JSON} The updated template data with current dates.
56 */
57 updateImageDates( templateData: JSON ): JSON {
58 const date = new Date();
59 const month = date.toLocaleString( 'default', { month: '2-digit' } );
60 const data = JSON.stringify( templateData );
61 const updatedData = data.replace( /[0-9]{4}\/[0-9]{2}/g, `${ date.getFullYear() }/${ month }` );
62 return JSON.parse( updatedData ) as JSON;
63 }
64
65 /**
66 * Upload SVG in the Media Library. Can be used on both Media Control and Icons Control.
67 *
68 * Please note that this method expects media library to be open as different controls
69 * have different ways to open the media library.
70 *
71 * @param {string} svgFileName - Optional. SVG file name, without extension.
72 *
73 * @return {Promise<void>}
74 */
75 async uploadSVG( svgFileName?: string ): Promise<void> {
76 const _svgFileName = svgFileName === undefined ? 'test-svg-wide' : svgFileName;
77 const regex = new RegExp( _svgFileName );
78 const response = this.page.waitForResponse( regex );
79 await this.page.setInputFiles( EditorSelectors.media.imageInp, _path.resolve( __dirname, `../resources/${ _svgFileName }.svg` ) );
80 await response;
81 await this.page.getByRole( 'button', { name: 'Insert Media' } )
82 .or( this.page.getByRole( 'button', { name: 'Select' } ) ).nth( 1 ).click();
83 }
84
85 /**
86 * Load a template from a file.
87 *
88 * @param {string} filePath - Path to the template file.
89 * @param {boolean} updateDatesForImages - Optional. Whether to update images dates. Default is false.
90 */
91 async loadTemplate( filePath: string, updateDatesForImages: boolean = false ): Promise<void> {
92 const rawFileData = await readFile( filePath );
93 let templateData = JSON.parse( rawFileData.toString() );
94
95 // For templates that use images, date when image is uploaded is hardcoded in template.
96 // Element regression tests upload images before each test.
97 // To update dates in template, use a flag updateDatesForImages = true
98 if ( updateDatesForImages ) {
99 templateData = this.updateImageDates( templateData );
100 }
101
102 await this.page.evaluate( ( data ) => {
103 const model = new Backbone.Model( { title: 'test' } );
104
105 window.$e.run( 'document/elements/import', {
106 data,
107 model,
108 options: {
109 at: 0,
110 withPageSettings: false,
111 },
112 } );
113 }, templateData );
114 }
115
116 async stabilizeForScreenshot( page: Page, editor?: any ): Promise<void> {
117 try {
118 if ( editor?.removeWpAdminBar ) {
119 await editor.removeWpAdminBar();
120 }
121 } catch {
122 }
123 await page.addStyleTag( { content: '*{transition:none!important;animation:none!important}*,*:before,*:after{transition:none!important;animation:none!important}' } )
124 .catch( () => {} );
125 await page.evaluate( async () => {
126 if ( document.fonts && 'ready' in document.fonts ) {
127 await ( document.fonts as FontFaceSet ).ready;
128 }
129 } ).catch( () => {} );
130 await page.waitForLoadState( 'networkidle' ).catch( () => {} );
131 await page.waitForTimeout( 100 );
132 }
133
134 /**
135 * Remove all the content from the page.
136 *
137 * @return {Promise<void>}
138 */
139 async cleanContent(): Promise<void> {
140 await this.page.evaluate( () => {
141 $e.run( 'document/elements/empty', { force: true } );
142 } );
143 }
144
145 /**
146 * Wait for the editor panels to finish loading.
147 *
148 * @return {Promise<void>}
149 */
150 async waitForPanelToLoad(): Promise<void> {
151 await this.page.waitForSelector( '.elementor-panel-loading', { state: 'detached' } );
152 await this.page.waitForSelector( '#elementor-loading', { state: 'hidden' } );
153 }
154
155 /**
156 * Add element to the page using a model.
157 *
158 * @param {Object} model - Model definition.
159 * @param {string} container - Optional. Container to create the element in.
160 * @param {boolean} isContainerASection - Optional. Whether the container is a section.
161 *
162 * @return {Promise<string>} Element ID
163 */
164 async addElement( model: unknown, container: null | string = null, isContainerASection = false ): Promise<string> {
165 return await this.page.evaluate( addElement, { model, container, isContainerASection } );
166 }
167
168 /**
169 * Remove element from the page.
170 *
171 * @param {string} elementId - Element ID.
172 *
173 * @return {Promise<void>}
174 */
175 async removeElement( elementId: string ): Promise<void> {
176 await this.page.evaluate( ( { id } ) => {
177 $e.run( 'document/elements/delete', {
178 container: elementor.getContainer( id ),
179 } );
180 }, { id: elementId } );
181 }
182
183 /**
184 * Add a widget by `widgetType`.
185 *
186 * @param {string} widgetType - Widget type.
187 * @param {string} container - Optional. Container to create the element in.
188 * @param {boolean} isContainerASection - Optional. Whether the container is a section.
189 *
190 * @return {Promise<string>} The widget ID.
191 */
192 async addWidget( widgetType: string, container: string = null, isContainerASection: boolean = false ): Promise<string> {
193 const widgetId = await this.addElement( { widgetType, elType: 'widget' }, container, isContainerASection );
194 await this.getPreviewFrame().waitForSelector( `[data-id='${ widgetId }']` );
195
196 return widgetId;
197 }
198
199 /**
200 * Add a page by importing a Json page object from PostMeta _elementor_data into Tests
201 *
202 * @param {string} dirName - Directory name, use `__dirname` for the current directory.
203 * @param {string} fileName - Name of the file without extension.
204 * @param {string} widgetSelector - Selector of the widget.
205 * @param {boolean} updateDatesForImages - Optional. Whether to update image dates in the template. Default is false.
206 *
207 * @return {Promise<void>}
208 */
209 async loadJsonPageTemplate( dirName: string, fileName: string, widgetSelector: string, updateDatesForImages: boolean = false ): Promise<void> {
210 const filePath = _path.resolve( dirName, `./templates/${ fileName }.json` );
211 const rawFileData = await readFile( filePath );
212 const templateData = JSON.parse( rawFileData.toString() );
213 const pageTemplateData =
214 {
215 content: templateData,
216 page_settings: [],
217 version: '0.4',
218 title: 'Elementor Test',
219 type: 'page',
220 };
221
222 // For templates that use images, date when image is uploaded is hardcoded in template.
223 // Element regression tests upload images before each test.
224 // To update dates in template, use a flag updateDatesForImages = true
225 if ( updateDatesForImages ) {
226 this.updateImageDates( templateData );
227 }
228
229 await this.page.evaluate( ( data ) => {
230 const model = new Backbone.Model( { title: 'test' } );
231
232 window.$e.run( 'document/elements/import', {
233 data,
234 model,
235 options: {
236 at: 0,
237 withPageSettings: false,
238 },
239 } );
240 }, pageTemplateData );
241
242 await this.waitForElement( false, widgetSelector );
243 }
244
245 /**
246 * Get element handle from the preview frame using its Container ID.
247 *
248 * @param {string} id - Container ID.
249 *
250 * @return {Promise<ElementHandle<SVGElement | HTMLElement> | null>} element handle
251 */
252 async getElementHandle( id: string ): Promise<ElementHandle<SVGElement | HTMLElement> | null> {
253 return this.getPreviewFrame().$( getElementSelector( id ) );
254 }
255
256 async waitForPreviewFrame(): Promise<Frame> {
257 await this.page.waitForSelector( '[id="elementor-preview-iframe"]', { timeout: timeouts.longAction } );
258
259 const frame = this.page.frame( { name: 'elementor-preview-iframe' } );
260 if ( ! frame ) {
261 throw new Error( 'Iframe is null even after it appeared in the DOM.' );
262 }
263
264 return frame;
265 }
266
267 /**
268 * Get the frame of the Elementor editor preview.
269 *
270 * @return {Frame} The preview iframe element.
271 */
272 getPreviewFrame(): Frame {
273 return this.page.frame( { name: 'elementor-preview-iframe' } );
274 }
275
276 /**
277 * Select an element inside the editor.
278 *
279 * @param {string} elementId - Element ID.
280 *
281 * @return {Promise<Locator>} element;
282 */
283 async selectElement( elementId: string ): Promise<Locator> {
284 await this.page.evaluate( ( { id } ) => {
285 $e.run( 'document/elements/select', {
286 container: elementor.getContainer( id ),
287 } );
288 }, { id: elementId } );
289
290 await this.getPreviewFrame().waitForSelector( '.elementor-element-' + elementId + '.elementor-element-editable' );
291 return this.getPreviewFrame().locator( '.elementor-element-' + elementId );
292 }
293
294 /**
295 * Add new container preset.
296 *
297 * @param {ContainerType} element - Element type. Available values: 'flex', 'grid'.
298 * @param {ContainerPreset} preset - Container preset.
299 *
300 * @return {Promise<void>}
301 */
302 async addNewContainerPreset( element: ContainerType, preset: ContainerPreset ): Promise<void> {
303 const frame = this.getPreviewFrame();
304 await frame.locator( '.elementor-add-section-button' ).click();
305 await frame.locator( `.${ element }-preset-button` ).click();
306 await frame.locator( `[data-preset=${ preset }]` ).click();
307 }
308
309 /**
310 * Open the section that adds a new element.
311 *
312 * @param {string} elementId - Element ID.
313 *
314 * @return {Promise<void>}
315 */
316 async openAddElementSection( elementId: string ): Promise<void> {
317 const element = this.getPreviewFrame().locator( `.elementor-edit-mode .elementor-element-${ elementId }` );
318 await element.hover();
319 const elementAddButton = this.getPreviewFrame().locator( `.elementor-edit-mode .elementor-element-${ elementId } > .elementor-element-overlay > .elementor-editor-element-settings > .elementor-editor-element-add` );
320 await elementAddButton.click();
321 await this.getPreviewFrame().waitForSelector( '.elementor-add-section-inline' );
322 }
323
324 async setWidgetTab( tab: 'content' | 'style' | 'advanced' ): Promise<void> {
325 await this.page.locator( `.elementor-tab-control-${ tab }` ).click();
326 }
327
328 /**
329 * Open a tab inside an Editor panel.
330 *
331 * @param {string} panelId - The panel tab to open.
332 *
333 * @return {Promise<void>}
334 */
335 async openPanelTab( panelId: string ): Promise<void> {
336 await this.page.waitForSelector( `.elementor-tab-control-${ panelId } span` );
337
338 // Check if panel has been activated already.
339 if ( await this.page.$( `.elementor-tab-control-${ panelId }.elementor-active` ) ) {
340 return;
341 }
342
343 await this.page.locator( `.elementor-tab-control-${ panelId } span` ).click();
344 await this.page.waitForSelector( `.elementor-tab-control-${ panelId }.elementor-active` );
345 }
346
347 /**
348 * Open a tab inside an Editor panel for V2 widgets.
349 *
350 * @param {'style' | 'general'} sectionName - The section to open.
351 *
352 * @return {Promise<void>}
353 */
354 async openV2PanelTab( sectionName: 'style' | 'general' ) {
355 const selectorMap: Record< 'style' | 'general', string > = {
356 style: 'style',
357 general: 'settings',
358 };
359 const sectionButtonSelector = `#tab-0-${ selectorMap[ sectionName ] }`,
360 sectionContentSelector = `#tabpanel-0-${ selectorMap[ sectionName ] }`,
361 isOpenSection = await this.page.evaluate( ( selector ) => {
362 const sectionContentElement: HTMLElement = document.querySelector( selector );
363
364 return ! sectionContentElement?.hidden;
365 }, sectionContentSelector );
366
367 if ( isOpenSection ) {
368 return;
369 }
370
371 await this.page.locator( sectionButtonSelector ).click();
372 await this.page.locator( sectionContentSelector ).waitFor();
373 }
374
375 /**
376 * Open a section in an active panel tab.
377 *
378 * @param {string} sectionId - The section to open.
379 *
380 * @return {Promise<void>}
381 */
382 async openSection( sectionId: string ): Promise<void> {
383 const sectionSelector = `.elementor-control-${ sectionId }`,
384 isOpenSection = await this.page.evaluate( ( selector ) => {
385 const sectionElement = document.querySelector( selector );
386
387 return sectionElement?.classList.contains( 'e-open' ) || sectionElement?.classList.contains( 'elementor-open' );
388 }, sectionSelector ),
389 section = await this.page.$( sectionSelector + ':not( .e-open ):not( .elementor-open ):visible' );
390
391 if ( ! section || isOpenSection ) {
392 return;
393 }
394
395 await this.page.locator( sectionSelector + ':not( .e-open ):not( .elementor-open ):visible' + ' .elementor-panel-heading' ).click();
396 }
397
398 /**
399 * Close a section in an active panel tab.
400 *
401 * @param {string} sectionId - The section to close.
402 *
403 * @return {Promise<void>}
404 */
405 async closeSection( sectionId: string ): Promise<void> {
406 const sectionSelector = `.elementor-control-${ sectionId }`,
407 isOpenSection = await this.page.evaluate( ( selector ) => {
408 const sectionElement = document.querySelector( selector );
409
410 return sectionElement?.classList.contains( 'e-open' ) || sectionElement?.classList.contains( 'elementor-open' );
411 }, sectionSelector ),
412 section = await this.page.$( sectionSelector + '.e-open:visible' );
413
414 if ( ! section || ! isOpenSection ) {
415 return;
416 }
417
418 await this.page.locator( sectionSelector + '.e-open:visible .elementor-panel-heading' ).click();
419 }
420
421 /**
422 * Open a section in an active panel tab.
423 *
424 * @param {string} sectionId - The section to open.
425 *
426 * @return {Promise<void>}
427 */
428 async openV2Section( sectionId: 'layout' | 'spacing' | 'size' | 'position' | 'typography' | 'background' | 'border' ) {
429 const sectionButton = this.page.locator( '.MuiButtonBase-root', { hasText: new RegExp( sectionId, 'i' ) } );
430 const contentSelector = await sectionButton.getAttribute( 'aria-controls' );
431 const isContentVisible = await this.page.evaluate( ( selector ) => {
432 return !! document.getElementById( selector );
433 }, contentSelector );
434
435 if ( isContentVisible ) {
436 return;
437 }
438
439 await sectionButton.click();
440 }
441
442 /**
443 * Set a custom width value to a widget.
444 *
445 * @param {string} width - Optional. The custom width value (as a percentage). Default is '100'.
446 *
447 * @return {Promise<void>}
448 */
449 async setWidgetCustomWidth( width: string = '100' ): Promise<void> {
450 await this.openPanelTab( 'advanced' );
451 await this.setSelectControlValue( '_element_width', 'initial' );
452 await this.setSliderControlValue( '_element_custom_width', width );
453 }
454
455 /**
456 * Set tab control value.
457 *
458 * @param {string} controlId - The control to select.
459 * @param {string} tabId - The tab to select.
460 *
461 * @return {Promise<void>}
462 */
463 async setTabControlValue( controlId: string, tabId: string ): Promise<void> {
464 await this.page.locator( `.elementor-control-${ controlId } .elementor-control-${ tabId }` ).first().click();
465 }
466
467 /**
468 * Set text control value.
469 *
470 * @param {string} controlId - The control to set the value to.
471 * @param {string} value - The value to set.
472 *
473 * @return {Promise<void>}
474 */
475 async setTextControlValue( controlId: string, value: string ): Promise<void> {
476 await this.page.locator( `.elementor-control-${ controlId } input` ).nth( 0 ).fill( value.toString() );
477 }
478
479 /**
480 * Set textarea control value.
481 *
482 * @param {string} controlId - The control to set the value to.
483 * @param {string} value - The value to set.
484 *
485 * @return {Promise<void>}
486 */
487 async setTextareaControlValue( controlId: string, value: string ): Promise<void> {
488 await this.page.locator( `.elementor-control-${ controlId } textarea` ).fill( value.toString() );
489 }
490
491 /**
492 * Set number control value.
493 *
494 * @param {string} controlId - The control to set the value to.
495 * @param {string} value - The value to set.
496 *
497 * @return {Promise<void>}
498 */
499 async setNumberControlValue( controlId: string, value: string ): Promise<void> {
500 await this.page.locator( `.elementor-control-${ controlId } input >> nth=0` ).fill( value.toString() );
501 }
502
503 /**
504 * Set slider control value.
505 *
506 * @param {string} controlId - The control to set the value to.
507 * @param {string} value - The value to set.
508 */
509 async setSliderControlValue( controlId: string, value: string ): Promise<void> {
510 await this.page.locator( `.elementor-control-${ controlId } .elementor-slider-input input` ).fill( value );
511 }
512
513 /**
514 * Set select control value.
515 *
516 * @param {string} controlId - The control to set the value to.
517 * @param {string} value - The value to set.
518 *
519 * @return {Promise<void>}
520 */
521 async setSelectControlValue( controlId: string, value: string ): Promise<void> {
522 await this.page.selectOption( `.elementor-control-${ controlId } select`, value );
523 }
524
525 /**
526 * Set select2 control value.
527 *
528 * @param {string} controlId - The control to set the value to.
529 * @param {string} value - The value to set.
530 * @param {boolean} exactMatch - Optional. Select only items that exactly match the provided value. Default is true.
531 *
532 * @return {Promise<void>}
533 */
534 async setSelect2ControlValue( controlId: string, value: string, exactMatch: boolean = true ): Promise<void> {
535 await this.page.locator( `.elementor-control-${ controlId } .select2:not( .select2-container--disabled )` ).click();
536 await this.page.locator( '.select2-search--dropdown input[type="search"]' ).first().fill( value );
537
538 if ( exactMatch ) {
539 await this.page.locator( `.select2-results__option:text-is("${ value }")` ).first().click();
540 } else {
541 await this.page.locator( `.select2-results__option:has-text("${ value }")` ).first().click();
542 }
543
544 await this.page.waitForLoadState( 'domcontentloaded' );
545 }
546
547 /**
548 * Set dimensions control value.
549 *
550 * @param {string} controlId - The control to set the value to.
551 * @param {string} value - The value to set.
552 *
553 * @return {Promise<void>}
554 */
555 async setDimensionsValue( controlId: string, value: string ): Promise<void> {
556 await this.page.locator( `.elementor-control-${ controlId } .elementor-control-dimensions li:first-child input` ).fill( value );
557 }
558
559 /**
560 * Set choose control value.
561 *
562 * TODO: For consistency, we need to rewrite the logic, from icon based to value based.
563 *
564 * @param {string} controlId - The control to set the value to.
565 * @param {string} icon - The icon to choose.
566 *
567 * @return {Promise<void>}
568 */
569 async setChooseControlValue( controlId: string, icon: string ): Promise<void> {
570 await this.page.locator( `.elementor-control-${ controlId } .${ icon }` ).click();
571 }
572
573 /**
574 * Set choose-image control value (custom Choose_Img_Control).
575 *
576 * @param {string} controlId - The control to set the value to.
577 * @param {string} value - The option value to choose (e.g., 'focus').
578 *
579 * @return {Promise<void>}
580 */
581 async setPresetImageControlValue( controlId: string, value: string ): Promise<void> {
582 const control = this.page.locator( `.elementor-control-${ controlId }` );
583 await control.locator( '.elementor-choices.elementor-choices-img' ).first().waitFor();
584 const choice = control.locator( '.elementor-choices-element' ).filter( { has: this.page.locator( `img.elementor-choices-image[data-hover="${ value }"]` ) } ).first();
585 await choice.scrollIntoViewIfNeeded();
586 await choice.locator( 'label.elementor-choices-label' ).first().click();
587 }
588
589 async setIconControlValueByName( controlId: string, iconName: string ): Promise<void> {
590 const control = this.page.locator( `.elementor-control-${ controlId }` );
591 await control.locator( '.elementor-control-icons--inline__icon' ).first().click();
592 await this.page.locator( '#elementor-icons-manager-modal' ).waitFor();
593 const item = this.page.locator( 'div' ).filter( { hasText: new RegExp( `^${ iconName }$` ) } ).first();
594 await item.waitFor();
595 await item.click();
596 await this.page.getByRole( 'button', { name: 'Insert' } ).click();
597 }
598
599 /**
600 * Set color control value.
601 *
602 * @param {string} controlId - The control to set the value to.
603 * @param {string} value - The value to set.
604 *
605 * @return {Promise<void>}
606 */
607 async setColorControlValue( controlId: string, value: string ): Promise<void> {
608 const controlSelector = `.elementor-control-${ controlId }`;
609
610 await this.page.locator( controlSelector + ' .pcr-button' ).click();
611 await this.page.locator( '.pcr-app.visible .pcr-interaction input.pcr-result' ).fill( value );
612 await this.page.locator( controlSelector ).click();
613 }
614
615 /**
616 * Set switcher control value.
617 *
618 * @param {string} controlId - The control to set the value to.
619 * @param {boolean} value - Optional. The value to set (true|false). Default is true.
620 *
621 * @return {Promise<void>}
622 */
623 async setSwitcherControlValue( controlId: string, value: boolean = true ): Promise<void> {
624 const controlSelector = `.elementor-control-${ controlId }`,
625 controlLabel = this.page.locator( controlSelector + ' label.elementor-switch' ),
626 currentState = await this.page.locator( controlSelector + ' input[type="checkbox"]' ).isChecked();
627
628 if ( currentState !== Boolean( value ) ) {
629 await controlLabel.click();
630 }
631 }
632
633 /**
634 * Set gap control value.
635 *
636 * @param {string} controlId - The control to set the value to.
637 * @param {GapControl} value - The value to set. Either a string or an object with column, row and unit values.
638 *
639 * @return {Promise<void>}
640 */
641 async setGapControlValue( controlId: string, value: GapControl ): Promise<void> {
642 const control = this.page.locator( `.elementor-control-${ controlId }` );
643
644 if ( 'string' === typeof value ) {
645 await control.locator( '.elementor-control-gap >> nth=0' ).locator( 'input' ).fill( value );
646 } else if ( 'object' === typeof value ) {
647 await control.locator( '.elementor-link-gaps' ).click();
648 await control.locator( '.elementor-control-gap input[data-setting="column"]' ).fill( value.column );
649 await control.locator( '.elementor-control-gap input[data-setting="row"]' ).fill( value.row );
650 if ( value.unit ) {
651 await control.locator( '.e-units-switcher' ).click();
652 await control.locator( `[data-choose="${ value.unit }"]` ).click();
653 }
654 }
655 }
656
657 /**
658 * Set an image on a media control.
659 *
660 * @param {string} controlId - The control to set the value to.
661 * @param {boolean} imageTitle - The title of the image to set.
662 *
663 * @return {Promise<void>}
664 */
665 async setMediaControlImageValue( controlId: string, imageTitle: string ): Promise<void> {
666 await this.page.locator( `.elementor-control-${ controlId } .elementor-control-media__preview` ).click();
667 await this.page.getByRole( 'tab', { name: 'Media Library' } ).click();
668 await this.page.locator( `[aria-label="${ imageTitle }"]` ).click();
669 await this.page.locator( '.button.media-button' ).click();
670 }
671
672 /**
673 * Set typography control value.
674 *
675 * @param {string} controlId - The control to set the value to.
676 * @param {string} fontsize - Font size value.
677 *
678 * @return {Promise<void>}
679 */
680 async setTypographyControlValue( controlId: string, fontsize: string ): Promise<void> {
681 const controlSelector = `.elementor-control-${ controlId }_typography .eicon-edit`;
682
683 await this.page.locator( controlSelector ).click();
684 await this.setSliderControlValue( controlId + '_font_size', fontsize );
685 await this.page.locator( controlSelector ).click();
686 }
687
688 async setShadowControlValue( controlId: string, shadowType: string ): Promise<void> {
689 await this.page.locator( `.elementor-control-${ controlId }_${ shadowType }_shadow_type i.eicon-edit` ).click();
690 await this.page.locator( `.elementor-control-${ controlId }_${ shadowType }_shadow_type label` ).first().click();
691 }
692
693 async setTextStrokeControlValue( controlId: string, strokeType: string, value: number, color: string ): Promise<void> {
694 await this.page.locator( `.elementor-control-${ controlId }_${ strokeType }_stroke_type i.eicon-edit` ).click();
695 await this.page.locator( `.elementor-control-${ controlId }_${ strokeType }_stroke input[type="number"]` ).first().fill( value.toString() );
696 await this.page.locator( `.elementor-control-${ controlId }_stroke_color .pcr-button` ).first().click();
697 await this.page.locator( '.pcr-app.visible .pcr-result' ).first().fill( color );
698 await this.page.locator( `.elementor-control-${ controlId }_${ strokeType }_stroke_type label` ).first().click();
699 }
700
701 async setWidgetMask(): Promise<void> {
702 await this.openSection( '_section_masking' );
703 await this.setSwitcherControlValue( '_mask_switch', true );
704 await this.setSelectControlValue( '_mask_size', 'custom' );
705 await this.setSliderControlValue( '_mask_size_scale', '30' );
706 await this.setSelectControlValue( '_mask_position', 'top right' );
707 }
708
709 /**
710 * Hide controls from the video widgets.
711 *
712 * @return {Promise<void>}
713 */
714 async hideVideoControls(): Promise<void> {
715 await this.getPreviewFrame().waitForSelector( '.elementor-video' );
716
717 const videoFrame = this.getPreviewFrame().frameLocator( '.elementor-video' ),
718 videoButton = videoFrame.locator( 'button.ytp-large-play-button.ytp-button.ytp-large-play-button-red-bg' ),
719 videoGradient = videoFrame.locator( '.ytp-gradient-top' ),
720 videoTitle = videoFrame.locator( '.ytp-show-cards-title' ),
721 videoBottom = videoFrame.locator( '.ytp-impression-link' );
722
723 await videoButton.evaluate( ( element ) => element.style.opacity = '0' );
724 await videoGradient.evaluate( ( element ) => element.style.opacity = '0' );
725 await videoTitle.evaluate( ( element ) => element.style.opacity = '0' );
726 await videoBottom.evaluate( ( element ) => element.style.opacity = '0' );
727 }
728
729 /**
730 * Hide controls and overlays on map widgets.
731 *
732 * @return {Promise<void>}
733 */
734 async hideMapControls(): Promise<void> {
735 await this.getPreviewFrame().waitForSelector( '.elementor-widget-google_maps iframe' );
736
737 const mapFrame = this.getPreviewFrame().frameLocator( '.elementor-widget-google_maps iframe' ),
738 mapText = mapFrame.locator( '.gm-style iframe + div + div' ),
739 mapInset = mapFrame.locator( 'button.gm-inset-map.gm-inset-light' ),
740 mapControls = mapFrame.locator( '.gmnoprint.gm-bundled-control.gm-bundled-control-on-bottom' );
741
742 await mapText.evaluate( ( element ) => element.style.opacity = '0' );
743 await mapInset.evaluate( ( element ) => element.style.opacity = '0' );
744 await mapControls.evaluate( ( element ) => element.style.opacity = '0' );
745 }
746
747 async hideContactMapControls(): Promise<void> {
748 await this.getPreviewFrame().waitForSelector( '.ehp-contact__map iframe' );
749
750 const mapFrame = this.getPreviewFrame().frameLocator( '.ehp-contact__map iframe' ),
751 mapText = mapFrame.locator( '.gm-style iframe + div + div' ),
752 mapInset = mapFrame.locator( 'button.gm-inset-map.gm-inset-light' ),
753 mapControls = mapFrame.locator( '.gmnoprint.gm-bundled-control.gm-bundled-control-on-bottom' );
754
755 if ( await mapText.count() > 0 ) {
756 await mapText.evaluate( ( element ) => element.style.opacity = '0' );
757 }
758 if ( await mapInset.count() > 0 ) {
759 await mapInset.evaluate( ( element ) => element.style.opacity = '0' );
760 }
761 if ( await mapControls.count() > 0 ) {
762 await mapControls.evaluate( ( element ) => element.style.opacity = '0' );
763 }
764 }
765
766 /**
767 * Open the page in the Preview mode.
768 *
769 * @return {Promise<void>}
770 */
771 async togglePreviewMode(): Promise<void> {
772 if ( ! await this.page.$( 'body.elementor-editor-preview' ) ) {
773 await this.page.locator( '#elementor-mode-switcher' ).click();
774 await this.page.waitForSelector( 'body.elementor-editor-preview' );
775 await this.page.waitForTimeout( 500 );
776 } else {
777 await this.page.locator( '#elementor-mode-switcher-preview' ).click();
778 await this.page.waitForSelector( 'body.elementor-editor-active' );
779 }
780 }
781
782 /**
783 * Wait for the Elementor preview to finish loading.
784 *
785 * @return {Promise<void>}
786 */
787 async waitForPreviewToLoad(): Promise<void> {
788 await this.page.waitForSelector( '#elementor-preview-loading' );
789 await this.page.waitForSelector( '#elementor-preview-loading', { state: 'hidden' } );
790 }
791
792 /**
793 * Hide all editor elements from the screenshots.
794 *
795 * @return {Promise<void>}
796 */
797 async hideEditorElements(): Promise<void> {
798 const css = '<style>.elementor-element-overlay,.elementor-empty-view{opacity: 0;}.elementor-widget,.elementor-widget:hover{box-shadow:none!important;}</style>';
799
800 await this.addWidget( 'html' );
801 await this.setTextareaControlValue( 'type-code', css );
802 }
803
804 /**
805 * Whether the Top Bar is active or not.
806 *
807 * @return {Promise<boolean>} Returns true if the Top Bar is visible, false otherwise.
808 */
809 async hasTopBar(): Promise<boolean> {
810 return await this.page.locator( EditorSelectors.panels.topBar.wrapper ).isVisible();
811 }
812
813 /**
814 * Click on a top bar item.
815 *
816 * @param {TopBarSelector} selector - The selector object for the top bar button.
817 *
818 * @return {Promise<void>}
819 */
820 async clickTopBarItem( selector: TopBarSelector ): Promise<void> {
821 const topbarLocator = this.page.locator( EditorSelectors.panels.topBar.wrapper );
822 if ( 'text' === selector.attribute ) {
823 await topbarLocator.getByRole( 'button', { name: selector.attributeValue } ).click();
824 } else {
825 await topbarLocator.locator( `button[${ selector.attribute }="${ selector.attributeValue }"]` ).click();
826 }
827 }
828
829 async clickTopBarMenuItem( menuLabel?: string ): Promise<void> {
830 await this.clickTopBarItem( TopBarSelectors.elementorLogo );
831 await this.page.waitForTimeout( 100 );
832
833 if ( menuLabel ) {
834 await this.page.getByRole( 'menuitem', { name: menuLabel } ).click();
835 }
836 }
837
838 /**
839 * Open the menu panel. Or, when an inner panel is provided, open the inner panel.
840 *
841 * TODO: Delete when Editor Top Bar feature is merged.
842 *
843 * @param {string} innerPanel - Optional. The inner menu to open.
844 *
845 * @return {Promise<void>}
846 */
847 async openMenuPanel( innerPanel?: string ): Promise<void> {
848 await this.page.locator( EditorSelectors.panels.menu.footerButton ).click();
849 await this.page.locator( EditorSelectors.panels.menu.wrapper ).waitFor();
850
851 if ( innerPanel ) {
852 await this.page.locator( `.elementor-panel-menu-item-${ innerPanel }` ).click();
853 }
854 }
855
856 /**
857 * Open the elements/widgets panel.
858 *
859 * @return {Promise<void>}
860 */
861 async openElementsPanel(): Promise<void> {
862 const hasTopBar = await this.hasTopBar();
863
864 if ( hasTopBar ) {
865 await this.clickTopBarItem( TopBarSelectors.elementsPanel );
866 } else {
867 await this.page.locator( EditorSelectors.panels.elements.footerButton ).click();
868 }
869
870 await this.page.locator( EditorSelectors.panels.elements.wrapper ).waitFor();
871 }
872
873 /**
874 * Open the page settings panel.
875 *
876 * @return {Promise<void>}
877 */
878 async openPageSettingsPanel(): Promise<void> {
879 const hasTopBar = await this.hasTopBar();
880
881 if ( hasTopBar ) {
882 await this.clickTopBarItem( TopBarSelectors.documentSettings );
883 } else {
884 await this.page.locator( EditorSelectors.panels.pageSettings.footerButton ).click();
885 }
886
887 await this.page.locator( EditorSelectors.panels.pageSettings.wrapper ).waitFor();
888 }
889
890 private async isElementorVersion3Dot33OrHigher(): Promise<boolean> {
891 const versionString = await this.page.locator( 'link#elementor-editor-css' ).getAttribute( 'href' );
892
893 if ( ! versionString ) {
894 return false;
895 }
896
897 const versionMatch = versionString.match( /ver=(\d+\.\d+)/ );
898
899 if ( ! versionMatch ) {
900 return false;
901 }
902
903 const version = parseFloat( versionMatch[ 1 ] );
904 const MINIMUM_VERSION = 3.33;
905
906 return version >= MINIMUM_VERSION;
907 }
908
909 /**
910 * Open the site settings panel. Or, when an inner panel is provided, open the inner panel.
911 *
912 * @param {string} innerPanel - Optional. The inner menu to open.
913 *
914 * @return {Promise<void>}
915 */
916 async openSiteSettings( innerPanel?: string ): Promise<void> {
917 const isNewVersion = await this.isElementorVersion3Dot33OrHigher();
918
919 if ( isNewVersion ) {
920 await this.clickTopBarMenuItem( 'Site Settings' );
921 } else {
922 const hasTopBar = await this.hasTopBar();
923
924 if ( hasTopBar ) {
925 await this.clickTopBarItem( TopBarSelectors.siteSettings );
926 } else {
927 await this.openMenuPanel( 'global-settings' );
928 }
929 }
930
931 await this.page.locator( EditorSelectors.panels.siteSettings.wrapper ).waitFor();
932
933 if ( innerPanel ) {
934 await this.page.locator( `.elementor-panel-menu-item-${ innerPanel }` ).click();
935 }
936 }
937
938 /**
939 * Open the user preferences panel.
940 *
941 * @return {Promise<void>}
942 */
943 async openUserPreferencesPanel(): Promise<void> {
944 const isNewVersion = await this.isElementorVersion3Dot33OrHigher();
945
946 if ( isNewVersion ) {
947 await this.clickTopBarMenuItem( 'User Preferences' );
948 } else {
949 const hasTopBar = await this.hasTopBar();
950
951 if ( hasTopBar ) {
952 await this.clickTopBarItem( TopBarSelectors.elementorLogo );
953 await this.page.waitForTimeout( 100 );
954 await this.page.getByRole( 'menuitem', { name: 'User Preferences' } ).click();
955 } else {
956 await this.openMenuPanel( 'editor-preferences' );
957 }
958 }
959
960 await this.page.locator( EditorSelectors.panels.userPreferences.wrapper ).waitFor();
961 }
962
963 /**
964 * Close the navigator/structure panel.
965 *
966 * @return {Promise<void>}
967 */
968 async closeNavigatorIfOpen(): Promise<void> {
969 await this.waitForPreviewFrame();
970 const isOpen = await this.getPreviewFrame().evaluate( () => elementor.navigator.isOpen() );
971
972 if ( ! isOpen ) {
973 return;
974 }
975
976 await this.page.locator( EditorSelectors.panels.navigator.closeButton ).click();
977 }
978
979 /**
980 * Set WordPress page template.
981 *
982 * @param {string} template - The page template to set. Available options: 'default', 'canvas', 'full-width'.
983 *
984 * @return {Promise<void>}
985 */
986 async setPageTemplate( template: 'default' | 'canvas' | 'full-width' ): Promise<void> {
987 let templateValue: string;
988 let templateClass: string;
989
990 switch ( template ) {
991 case 'default':
992 templateValue = 'default';
993 templateClass = '.elementor-default';
994 break;
995 case 'canvas':
996 templateValue = 'elementor_canvas';
997 templateClass = '.elementor-template-canvas';
998 break;
999 case 'full-width':
1000 templateValue = 'elementor_header_footer';
1001 templateClass = '.elementor-template-full-width';
1002 break;
1003 }
1004
1005 // Check if the template is already set
1006 if ( await this.getPreviewFrame().$( templateClass ) ) {
1007 return;
1008 }
1009
1010 // Select the template
1011 await this.openPageSettingsPanel();
1012 await this.setSelectControlValue( 'template', templateValue );
1013 await this.getPreviewFrame().waitForSelector( templateClass );
1014 }
1015
1016 /**
1017 * Change the display mode of the editor.
1018 *
1019 * @param {string} uiMode - Either 'light', 'dark', or 'auto'.
1020 *
1021 * @return {Promise<void>}
1022 */
1023 async setDisplayMode( uiMode: string ): Promise<void> {
1024 const uiThemeOptions = {
1025 light: 'eicon-light-mode',
1026 dark: 'eicon-dark-mode',
1027 auto: 'eicon-header',
1028 };
1029
1030 await this.openUserPreferencesPanel();
1031 await this.setChooseControlValue( 'ui_theme', uiThemeOptions[ uiMode ] );
1032 }
1033
1034 /**
1035 * Open the responsive view bar.
1036 *
1037 * TODO: Delete when Editor Top Bar feature is merged.
1038 *
1039 * @return {Promise<void>}
1040 */
1041 async openResponsiveViewBar(): Promise<void> {
1042 const hasResponsiveViewBar = await this.page.evaluate( () => elementor.isDeviceModeActive() );
1043
1044 if ( ! hasResponsiveViewBar ) {
1045 await this.page.locator( '#elementor-panel-footer-responsive i' ).click();
1046 }
1047 }
1048
1049 /**
1050 * Select a responsive view.
1051 *
1052 * @param {Device} device - The name of the device breakpoint, such as `tablet_extra`.
1053 *
1054 * @return {Promise<void>}
1055 */
1056 async changeResponsiveView( device: Device ): Promise<void> {
1057 const hasTopBar = await this.hasTopBar();
1058 if ( hasTopBar ) {
1059 await Breakpoints.getDeviceLocator( this.page, device ).click();
1060 } else {
1061 await this.openResponsiveViewBar();
1062 await this.page.locator( `#e-responsive-bar-switcher__option-${ device }` ).first().locator( 'i' ).click();
1063 }
1064 }
1065
1066 /**
1067 * Publish the current page.
1068 *
1069 * @return {Promise<void>}
1070 */
1071 async publishPage(): Promise<void> {
1072 const hasTopBar = await this.hasTopBar();
1073
1074 if ( hasTopBar ) {
1075 await this.clickTopBarItem( TopBarSelectors.publish );
1076 await this.page.waitForLoadState();
1077 await this.page.locator( EditorSelectors.panels.topBar.wrapper + ' button[disabled]', { hasText: 'Publish' } ).waitFor( { timeout: timeouts.longAction } );
1078 } else {
1079 await this.page.locator( 'button#elementor-panel-saver-button-publish' ).click();
1080 await this.page.waitForLoadState();
1081 await this.page.getByRole( 'button', { name: 'Update' } ).waitFor();
1082 }
1083 }
1084
1085 /**
1086 * Publish the current page and view it.
1087 *
1088 * @return {Promise<void>}
1089 */
1090 async publishAndViewPage(): Promise<void> {
1091 await this.publishPage();
1092 await this.viewPage();
1093 }
1094
1095 async viewPage() {
1096 const pageId = await this.getPageId();
1097
1098 if ( ! pageId ) {
1099 return;
1100 }
1101
1102 await this.page.goto( `/?p=${ pageId }` );
1103 await this.page.waitForLoadState();
1104 }
1105
1106 /**
1107 * Get a control value by index with modulo cycling for array access.
1108 *
1109 * @param {Array} controlValues - Array of control values.
1110 * @param {number} loopIndex - The loop index.
1111 *
1112 * @return {any} The control value at the calculated index.
1113 */
1114 getControlValueByIndex( controlValues: any[], loopIndex: number ): any {
1115 return controlValues[ loopIndex % controlValues.length ];
1116 }
1117
1118 /**
1119 * Set background color control value with proper visibility check.
1120 * Ensures the color picker is opened before setting the color value.
1121 *
1122 * @param {string} backgroundControlId - The background control ID (e.g., 'background_background').
1123 * @param {string} colorControlId - The color control ID (e.g., 'background_color').
1124 * @param {string} colorValue - The color value to set.
1125 *
1126 * @return {Promise<void>}
1127 */
1128 async setBackgroundColorControlValue( backgroundControlId: string, colorControlId: string, colorValue: string ): Promise<void> {
1129 const colorControl = this.page.locator( `.elementor-control-${ colorControlId }` );
1130
1131 if ( ! await colorControl.isVisible() ) {
1132 await this.setChooseControlValue( backgroundControlId, 'eicon-paint-brush' );
1133 }
1134
1135 await this.setColorControlValue( colorControlId, colorValue );
1136 }
1137
1138 /**
1139 * Save and reload the current page.
1140 *
1141 * @return {Promise<void>}
1142 */
1143 async saveAndReloadPage(): Promise<void> {
1144 const hasTopBar = await this.hasTopBar();
1145
1146 if ( hasTopBar ) {
1147 await this.clickTopBarItem( TopBarSelectors.publish );
1148 } else {
1149 await this.page.locator( '#elementor-panel-saver-button-publish' ).click();
1150 }
1151
1152 await this.page.waitForLoadState();
1153 await this.page.waitForResponse( '/wp-admin/admin-ajax.php' );
1154 await this.page.reload();
1155 }
1156
1157 /**
1158 * Get the current page ID.
1159 *
1160 * @return {Promise<string>} The ID of the current page.
1161 */
1162 async getPageId(): Promise<string | null> {
1163 return await this.page.evaluate( () => {
1164 const urlParams = new URLSearchParams( window.location.search );
1165 return urlParams.get( 'post' );
1166 } );
1167 }
1168
1169 /**
1170 * Apply Element Settings
1171 *
1172 * Apply settings to a widget without having to navigate through its Panels and Sections to set each individual
1173 * control value.
1174 *
1175 * You can get the Element settings by right-clicking an existing widget or element in the Editor, choose "Copy",
1176 * then paste the content into a text editor and filter out just the settings you want to apply to your element.
1177 *
1178 * Example usage:
1179 * ```
1180 * await editor.applyElementSettings( 'cdefd82', {
1181 * background_background: 'classic',
1182 * background_color: 'rgb(255, 10, 10)',
1183 * } );
1184 * ```
1185 *
1186 * @param {string} elementId - Id of the element you intend to apply the settings to.
1187 * @param {Object} settings - Object settings from the Editor > choose element > right-click > "Copy".
1188 *
1189 * @return {Promise<void>}
1190 */
1191 async applyElementSettings( elementId: string, settings: unknown ): Promise<void> {
1192 await this.page.evaluate(
1193 ( args ) => $e.run( 'document/elements/settings', {
1194 container: elementor.getContainer( args.elementId ),
1195 settings: args.settings,
1196 } ),
1197 { elementId, settings },
1198 );
1199 }
1200
1201 /**
1202 * Check if an item is in the viewport.
1203 *
1204 * @param {string} itemSelector - The item selector.
1205 *
1206 * @return {Promise<boolean>} Returns true if the item is in the viewport, false otherwise.
1207 */
1208 async isItemInViewport( itemSelector: string ): Promise<boolean> {
1209 return this.page.evaluate( ( item: string ) => {
1210 let isVisible = false;
1211
1212 const element: HTMLElement = document.querySelector( item );
1213
1214 if ( element ) {
1215 const rect = element.getBoundingClientRect();
1216
1217 if ( rect.top >= 0 && rect.left >= 0 ) {
1218 const vw = Math.max( document.documentElement.clientWidth || 0, window.innerWidth || 0 ),
1219 vh = Math.max( document.documentElement.clientHeight || 0, window.innerHeight || 0 );
1220
1221 if ( rect.right <= vw && rect.bottom <= vh ) {
1222 isVisible = true;
1223 }
1224 }
1225 }
1226 return isVisible;
1227 }, itemSelector );
1228 }
1229
1230 /**
1231 * Get the number of widgets in the editor.
1232 *
1233 * @return {Promise<number>} The number of widgets in the editor.
1234 */
1235 async getWidgetCount(): Promise<number> {
1236 return ( await this.getPreviewFrame().$$( EditorSelectors.widget ) ).length;
1237 }
1238
1239 /**
1240 * Based on the widget type, wait for the iframe to load.
1241 *
1242 * @param {string} widgetType - The widget type. Available options: 'video', 'google_maps', 'sound_cloud'.
1243 * @param {boolean} isPublished - Optional. Whether the element is published. Default is false.
1244 *
1245 * @return {Promise<void>}
1246 */
1247 async waitForIframeToLoaded( widgetType: string, isPublished: boolean = false ): Promise<void> {
1248 const frames = {
1249 video: [ EditorSelectors.video.iframe, EditorSelectors.video.playIcon ],
1250 google_maps: [ EditorSelectors.googleMaps.iframe, EditorSelectors.googleMaps.showSatelliteViewBtn ],
1251 sound_cloud: [ EditorSelectors.soundCloud.iframe, EditorSelectors.soundCloud.waveForm ],
1252 };
1253
1254 if ( ! ( widgetType in frames ) ) {
1255 return;
1256 }
1257
1258 if ( isPublished ) {
1259 await this.page.locator( frames[ widgetType ][ 0 ] ).first().waitFor();
1260 const count = await this.page.locator( frames[ widgetType ][ 0 ] ).count();
1261 for ( let i = 1; i < count; i++ ) {
1262 await this.page.frameLocator( frames[ widgetType ][ 0 ] ).nth( i ).locator( frames[ widgetType ][ 1 ] ).waitFor();
1263 }
1264 } else {
1265 const frame = this.getPreviewFrame();
1266 await frame.waitForLoadState();
1267 await frame.waitForSelector( frames[ widgetType ][ 0 ] );
1268 await frame.frameLocator( frames[ widgetType ][ 0 ] ).first().locator( frames[ widgetType ][ 1 ] ).waitFor();
1269 const iframeCount: number = await new Promise( ( resolved ) => {
1270 resolved( frame.childFrames().length );
1271 } );
1272 for ( let i = 1; i < iframeCount; i++ ) {
1273 await frame.frameLocator( frames[ widgetType ][ 0 ] ).nth( i ).locator( frames[ widgetType ][ 1 ] ).waitFor();
1274 }
1275 }
1276 }
1277
1278 /**
1279 * Wait for the element to be visible.
1280 *
1281 * @param {boolean} isPublished - Whether the element is published.
1282 * @param {string} selector - Element selector.
1283 *
1284 * @return {Promise<void>}
1285 */
1286 async waitForElement( isPublished: boolean, selector: string ): Promise<void> {
1287 if ( selector === undefined ) {
1288 return;
1289 }
1290
1291 if ( isPublished ) {
1292 await this.page.waitForSelector( selector );
1293 } else {
1294 const frame = this.getPreviewFrame();
1295 await frame.waitForLoadState();
1296 await frame.waitForSelector( selector );
1297 }
1298 }
1299
1300 /**
1301 * Verify class in element.
1302 *
1303 * @param {Object} args - Arguments.
1304 * @param {string} args.selector - Element selector.
1305 * @param {string} args.className - Class name.
1306 * @param {boolean} args.isPublished - Whether the element is published.
1307 *
1308 * @return {Promise<void>}
1309 */
1310 async verifyClassInElement( args: { selector: string, className: string, isPublished: boolean } ): Promise<void> {
1311 const regex = new RegExp( args.className );
1312 if ( args.isPublished ) {
1313 await expect( this.page.locator( args.selector ) ).toHaveClass( regex );
1314 } else {
1315 await expect( this.getPreviewFrame().locator( args.selector ) ).toHaveClass( regex );
1316 }
1317 }
1318
1319 /**
1320 * Verify image size.
1321 *
1322 * @param {Object} args - Arguments.
1323 * @param {string} args.selector - Element selector.
1324 * @param {number} args.width - Image width.
1325 * @param {number} args.height - Image height.
1326 * @param {boolean} args.isPublished - Whether the element is published.
1327 *
1328 * @return {Promise<void>}
1329 */
1330 async verifyImageSize( args: { selector: string, width: number, height: number, isPublished: boolean } ): Promise<void> {
1331 const imageSize = args.isPublished
1332 ? await this.page.locator( args.selector ).boundingBox()
1333 : await this.getPreviewFrame().locator( args.selector ).boundingBox();
1334 expect( imageSize.width ).toEqual( args.width );
1335 expect( imageSize.height ).toEqual( args.height );
1336 }
1337
1338 /**
1339 * Checks for a stable UI state by comparing screenshots at intervals and expecting a match.
1340 * Can be used to check for completed rendering. Useful to wait out animations before screenshots and expects.
1341 * Should be less flaky than waitForLoadState( 'load' ) in editor where Ajax re-rendering is triggered.
1342 *
1343 * @param {Locator} locator - The locator to check for.
1344 * @param {number} retries - Optional. Number of retries. Default is 3.
1345 * @param {number} timeout - Optional. Time to wait between retries, in milliseconds. Default is 500.
1346 *
1347 * @return {Promise<void>}
1348 */
1349 async isUiStable( locator: Locator, retries: number = 3, timeout: number = 500 ): Promise<void> {
1350 const comparator = getComparator( 'image/png' );
1351 let retry = 0,
1352 beforeImage: Buffer,
1353 afterImage: Buffer;
1354
1355 await locator.waitFor();
1356
1357 do {
1358 if ( retry === retries ) {
1359 break;
1360 }
1361
1362 beforeImage = await locator.screenshot( {
1363 path: `./before.png`,
1364 } );
1365
1366 await new Promise( ( resolve ) => setTimeout( resolve, timeout ) );
1367
1368 afterImage = await locator.screenshot( {
1369 path: `./after.png`,
1370 } );
1371 retry = retry++;
1372 } while ( null !== comparator( beforeImage, afterImage ) );
1373 }
1374
1375 /**
1376 * Remove classes from the page.
1377 *
1378 * @param {string} className - The class to remove.
1379 *
1380 * @return {Promise<void>}
1381 */
1382 async removeClasses( className: string ): Promise<void> {
1383 await this.page.evaluate( async ( _class ) => {
1384 await new Promise( ( resolve1 ) => {
1385 const elems = document.querySelectorAll( `.${ _class }` );
1386
1387 [].forEach.call( elems, function( el: HTMLElement ) {
1388 el.classList.remove( _class );
1389 } );
1390 resolve1( 'Foo' );
1391 } );
1392 }, className );
1393 }
1394
1395 /**
1396 * Scroll the page.
1397 *
1398 * @return {Promise<void>}
1399 */
1400 async scrollPage(): Promise<void> {
1401 await this.page.evaluate( async () => {
1402 await new Promise( ( resolve1 ) => {
1403 let totalHeight = 0;
1404 const distance = 400;
1405 const timer = setInterval( () => {
1406 const scrollHeight = document.body.scrollHeight;
1407 window.scrollBy( 0, distance );
1408 totalHeight += distance;
1409 if ( totalHeight >= scrollHeight ) {
1410 clearInterval( timer );
1411 window.scrollTo( 0, 0 );
1412 resolve1( 'Foo' );
1413 }
1414 }, 100 );
1415 } );
1416 } );
1417 }
1418
1419 /**
1420 * Remove the WordPress admin bar.
1421 *
1422 * @return {Promise<void>}
1423 */
1424 async removeWpAdminBar(): Promise<void> {
1425 const adminBar = 'wpadminbar';
1426 await this.page.locator( `#${ adminBar }` ).waitFor( { timeout: timeouts.longAction } );
1427 await this.page.evaluate( ( selector ) => {
1428 const admin = document.getElementById( selector );
1429 admin.remove();
1430 }, adminBar );
1431 }
1432
1433 /**
1434 * Isolated ID number.
1435 *
1436 * @param {string} idPrefix - The prefix of the item.
1437 * @param {string} itemID - The item ID.
1438 *
1439 * @return {Promise<number>} The numeric part of the ID with the prefix removed.
1440 */
1441 async isolatedIdNumber( idPrefix: string, itemID: string ): Promise<number> {
1442 return Number( itemID.replace( idPrefix, '' ) );
1443 }
1444
1445 async addImagesToGalleryControl( args?: { images?: string[], metaData?: boolean } ) {
1446 const defaultImages = [ 'A.jpg', 'B.jpg', 'C.jpg', 'D.jpg', 'E.jpg' ];
1447
1448 await this.page.locator( EditorSelectors.galleryControl.addGalleryBtn ).nth( 0 ).click();
1449 await this.page.getByRole( 'tab', { name: 'Media Library' } ).click();
1450
1451 const _images = args?.images === undefined ? defaultImages : args.images;
1452
1453 for ( const i in _images ) {
1454 await this.page.setInputFiles( EditorSelectors.media.imageInp, pathResolve( __dirname, `../resources/${ _images[ i ] }` ) );
1455
1456 if ( args?.metaData ) {
1457 await this.addTestImageMetaData();
1458 }
1459 }
1460
1461 await this.page.locator( EditorSelectors.media.addGalleryButton ).click();
1462 await this.page.locator( 'text=Insert gallery' ).click();
1463 }
1464
1465 async addTestImageMetaData( args = { caption: 'Test caption!', description: 'Test description!' } ) {
1466 await this.page.locator( EditorSelectors.media.images ).first().click();
1467 await this.page.locator( EditorSelectors.media.imgCaption ).clear();
1468 await this.page.locator( EditorSelectors.media.imgCaption ).type( args.caption );
1469
1470 await this.page.locator( EditorSelectors.media.images ).first().click();
1471 await this.page.locator( EditorSelectors.media.imgDescription ).clear();
1472 await this.page.locator( EditorSelectors.media.imgDescription ).type( args.description );
1473 }
1474
1475 /**
1476 * Save the site settings with the top bar.
1477 *
1478 * TODO: Rename when Editor Top Bar feature is merged.
1479 *
1480 * @param {boolean} toReload - Whether to reload the page after saving.
1481 *
1482 * @return {Promise<void>}
1483 */
1484 async saveSiteSettingsWithTopBar( toReload: boolean ): Promise<void> {
1485 if ( await this.page.locator( EditorSelectors.panels.siteSettings.saveButton ).isEnabled() ) {
1486 await this.page.locator( EditorSelectors.panels.siteSettings.saveButton ).click();
1487 } else {
1488 await this.page.evaluate( ( selector ) => {
1489 const button: HTMLElement = document.evaluate( selector, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE ).singleNodeValue as HTMLElement;
1490 button.click();
1491 }, EditorSelectors.panels.siteSettings.saveButton );
1492 }
1493
1494 if ( toReload ) {
1495 await this.page.locator( EditorSelectors.refreshPopup.reloadButton ).click();
1496 }
1497 }
1498
1499 /**
1500 * Save the site settings without the top bar.
1501 *
1502 * TODO: Delete when Editor Top Bar feature is merged.
1503 *
1504 * @return {Promise<void>}
1505 */
1506 async saveSiteSettingsNoTopBar(): Promise<void> {
1507 await this.page.locator( EditorSelectors.panels.footerTools.updateButton ).click();
1508 await this.page.locator( EditorSelectors.toast ).waitFor();
1509 }
1510
1511 async assertCorrectVwWidthStylingOfElement( element: Locator, vwValue: number = 100 ): Promise<void> {
1512 const viewport = this.page.viewportSize();
1513 const vwConvertedToPxUnit = viewport.width * vwValue / 100;
1514 const elementWidthInPxUnit = await element.boundingBox().then( ( box ) => box?.width ?? 0 );
1515 const vwAndPxValuesAreEqual = Math.abs( vwConvertedToPxUnit - elementWidthInPxUnit ) <= 1;
1516 expect( vwAndPxValuesAreEqual ).toBeTruthy();
1517 }
1518
1519 async clearTemplates(): Promise<void> {
1520 const checkbox = this.page.locator( '.wp-list-table' ).first().locator( '[type="checkbox"]' ).first();
1521
1522 if ( ! await checkbox.isVisible() ) {
1523 return;
1524 }
1525
1526 await checkbox.check();
1527 const bulkActionSelector = this.page.locator( '#bulk-action-selector-top' );
1528 const trashOptionExists = await bulkActionSelector.locator( 'option[value="trash"]' ).count() > 0;
1529
1530 if ( ! trashOptionExists ) {
1531 return;
1532 }
1533
1534 await bulkActionSelector.selectOption( 'trash' );
1535 await this.page.locator( '#doaction' ).click();
1536 }
1537
1538 async importTemplateUI( filePath: string ) {
1539 await this.page.getByRole( 'link', { name: 'Templates', exact: true } ).click();
1540 await this.page.getByRole( 'link', { name: 'Hello+ Header' } ).first().click();
1541
1542 await this.clearTemplates();
1543
1544 await this.page.getByRole( 'link', { name: 'Add New Template' } ).click();
1545 await this.page.selectOption( '#elementor-new-template__form__template-type', 'ehp-header' );
1546 await this.page.getByRole( 'button', { name: 'Create Template' } ).click();
1547 await this.ensurePanelLoaded();
1548 await this.page.getByText( 'Templates', { exact: true } ).click();
1549 await this.page.getByText( 'Site templates' ).click();
1550 await this.page.locator( EditorSelectors.templateImport.importIcon ).click();
1551 await this.page.getByText( 'Select File' ).click();
1552 await this.page.locator( EditorSelectors.media.imageInp ).setInputFiles( filePath );
1553 await this.page.getByRole( 'button', { name: 'Continue' } ).click();
1554 const enableImportBtn = this.page.getByRole( 'button', { name: 'Enable and Import' } );
1555 if ( await enableImportBtn.count() > 0 ) {
1556 await enableImportBtn.click();
1557 }
1558 await this.page.getByRole( 'button', { name: 'Insert' } ).first().click();
1559 }
1560
1561 /**
1562 * Make sure that the elements panel is loaded.
1563 *
1564 * @return {Promise<void>}
1565 */
1566 async ensurePanelLoaded(): Promise<void> {
1567 if ( this.isPanelLoaded ) {
1568 return;
1569 }
1570
1571 await this.page.waitForSelector( '.elementor-panel-loading', { state: 'detached' } );
1572 await this.page.waitForSelector( '#elementor-loading', { state: 'hidden' } );
1573
1574 this.isPanelLoaded = true;
1575 }
1576 }
1577