1
0
Fork 0
mirror of https://github.com/elementor/hello-theme.git synced 2026-07-30 15:16:56 +08:00
hello-theme/tests/playwright/pages/editor-page.ts
Rami Yushuvaev aa9eed2933
Some checks failed
Build / Build theme (push) Failing after 1s
Lint / ESLint (push) Failing after 0s
PHPUnit / File Diff (push) Failing after 1s
Lint / PHPCS (push) Failing after 41s
PHPUnit / PHPUnit - WordPress 6.8 - PHP version 7.4 (push) Has been skipped
PHPUnit / PHPUnit - WordPress 6.9 - PHP version 7.4 (push) Has been skipped
PHPUnit / PHPUnit - WordPress latest - PHP version 7.4 (push) Has been skipped
PHPUnit / PHPUnit - WordPress nightly - PHP version 7.4 (push) Has been skipped
PHPUnit / PHPUnit - WordPress 6.8 - PHP version 8.0 (push) Has been skipped
PHPUnit / PHPUnit - WordPress 6.9 - PHP version 8.0 (push) Has been skipped
PHPUnit / PHPUnit - WordPress latest - PHP version 8.0 (push) Has been skipped
PHPUnit / PHPUnit - WordPress nightly - PHP version 8.0 (push) Has been skipped
PHPUnit / PHPUnit - WordPress 6.8 - PHP version 8.1 (push) Has been skipped
PHPUnit / PHPUnit - WordPress 6.9 - PHP version 8.1 (push) Has been skipped
PHPUnit / PHPUnit - WordPress latest - PHP version 8.1 (push) Has been skipped
PHPUnit / PHPUnit - WordPress nightly - PHP version 8.1 (push) Has been skipped
PHPUnit / PHPUnit - WordPress 6.8 - PHP version 8.2 (push) Has been skipped
PHPUnit / PHPUnit - WordPress 6.9 - PHP version 8.2 (push) Has been skipped
PHPUnit / PHPUnit - WordPress latest - PHP version 8.2 (push) Has been skipped
PHPUnit / PHPUnit - WordPress nightly - PHP version 8.2 (push) Has been skipped
PHPUnit / PHPUnit - WordPress 6.8 - PHP version 8.3 (push) Has been skipped
PHPUnit / PHPUnit - WordPress 6.9 - PHP version 8.3 (push) Has been skipped
PHPUnit / PHPUnit - WordPress latest - PHP version 8.3 (push) Has been skipped
PHPUnit / PHPUnit - WordPress nightly - PHP version 8.3 (push) Has been skipped
PHPUnit / PHPUnit - Test Results (push) Successful in 2s
Internal: Setup prettier and fix all lint/format issues [TMZ-1051] (#671)
* Setup prettier

* Fix lint/format issues
2026-07-01 00:41:16 -07:00

1734 lines
46 KiB
TypeScript

import { readFile } from 'fs/promises';
import { addElement, getElementSelector } from '../assets/elements-utils.ts';
import {
expect,
type Page,
type Frame,
type TestInfo,
type ElementHandle,
Locator,
} from '@playwright/test';
import BasePage from './base-page.ts';
import EditorSelectors from '../selectors/editor-selectors.ts';
import _path, { resolve as pathResolve } from 'path';
// eslint-disable-next-line import/no-extraneous-dependencies
import { getComparator } from 'playwright-core/lib/utils';
import {
$eType,
Device,
WindowType,
BackboneType,
ElementorType,
GapControl,
ContainerType,
ContainerPreset,
} from '../types/types.ts';
import TopBarSelectors, {
TopBarSelector,
} from '../selectors/top-bar-selectors.ts';
import Breakpoints from '../assets/breakpoints.ts';
import { timeouts } from '../config/timeouts.ts';
let $e: $eType;
let elementor: ElementorType;
let Backbone: BackboneType;
let window: WindowType;
export default class EditorPage extends BasePage {
readonly previewFrame: Frame;
postId: number;
/**
* Create an Elementor editor page.
*
* @param {Page} page - Playwright page instance.
* @param {TestInfo} testInfo - Test information.
* @param {number} cleanPostId - Optional. Post ID.
*/
constructor(
page: Page,
testInfo: TestInfo,
cleanPostId: null | number = null,
) {
super(page, testInfo);
this.previewFrame = this.getPreviewFrame();
this.postId = cleanPostId;
}
/**
* Open a specific post in the elementor editor.
*
* @param {number|string} id - Optional. Post ID. Default is the ID of the current post.
*
* @return {Promise<void>}
*/
async gotoPostId(id: number | string = this.postId): Promise<void> {
await this.page.goto(`wp-admin/post.php?post=${id}&action=elementor`);
await this.page.waitForLoadState('load');
await this.waitForPanelToLoad();
}
/**
* Update image dates in the template data.
*
* @param {JSON} templateData - Template data.
*
* @return {JSON} The updated template data with current dates.
*/
updateImageDates(templateData: JSON): JSON {
const date = new Date();
const month = date.toLocaleString('default', { month: '2-digit' });
const data = JSON.stringify(templateData);
const updatedData = data.replace(
/[0-9]{4}\/[0-9]{2}/g,
`${date.getFullYear()}/${month}`,
);
return JSON.parse(updatedData) as JSON;
}
/**
* Upload SVG in the Media Library. Can be used on both Media Control and Icons Control.
*
* Please note that this method expects media library to be open as different controls
* have different ways to open the media library.
*
* @param {string} svgFileName - Optional. SVG file name, without extension.
*
* @return {Promise<void>}
*/
async uploadSVG(svgFileName?: string): Promise<void> {
const _svgFileName =
svgFileName === undefined ? 'test-svg-wide' : svgFileName;
const regex = new RegExp(_svgFileName);
const response = this.page.waitForResponse(regex);
await this.page.setInputFiles(
EditorSelectors.media.imageInp,
_path.resolve(__dirname, `../resources/${_svgFileName}.svg`),
);
await response;
await this.page
.getByRole('button', { name: 'Insert Media' })
.or(this.page.getByRole('button', { name: 'Select' }))
.nth(1)
.click();
}
/**
* Load a template from a file.
*
* @param {string} filePath - Path to the template file.
* @param {boolean} updateDatesForImages - Optional. Whether to update images dates. Default is false.
*/
async loadTemplate(
filePath: string,
updateDatesForImages: boolean = false,
): Promise<void> {
const rawFileData = await readFile(filePath);
let templateData = JSON.parse(rawFileData.toString());
// For templates that use images, date when image is uploaded is hardcoded in template.
// Element regression tests upload images before each test.
// To update dates in template, use a flag updateDatesForImages = true
if (updateDatesForImages) {
templateData = this.updateImageDates(templateData);
}
await this.page.evaluate((data) => {
const model = new Backbone.Model({ title: 'test' });
window.$e.run('document/elements/import', {
data,
model,
options: {
at: 0,
withPageSettings: false,
},
});
}, templateData);
}
/**
* Remove all the content from the page.
*
* @return {Promise<void>}
*/
async cleanContent(): Promise<void> {
await this.page.evaluate(() => {
$e.run('document/elements/empty', { force: true });
});
}
/**
* Wait for the editor panels to finish loading.
*
* @return {Promise<void>}
*/
async waitForPanelToLoad(): Promise<void> {
await this.page.waitForSelector('.elementor-panel-loading', {
state: 'detached',
});
await this.page.waitForSelector('#elementor-loading', { state: 'hidden' });
}
/**
* Add element to the page using a model.
*
* @param {Object} model - Model definition.
* @param {string} container - Optional. Container to create the element in.
* @param {boolean} isContainerASection - Optional. Whether the container is a section.
*
* @return {Promise<string>} Element ID
*/
async addElement(
model: unknown,
container: null | string = null,
isContainerASection = false,
): Promise<string> {
return await this.page.evaluate(addElement, {
model,
container,
isContainerASection,
});
}
/**
* Remove element from the page.
*
* @param {string} elementId - Element ID.
*
* @return {Promise<void>}
*/
async removeElement(elementId: string): Promise<void> {
await this.page.evaluate(
({ id }) => {
$e.run('document/elements/delete', {
container: elementor.getContainer(id),
});
},
{ id: elementId },
);
}
/**
* Add a widget by `widgetType`.
*
* @param {string} widgetType - Widget type.
* @param {string} container - Optional. Container to create the element in.
* @param {boolean} isContainerASection - Optional. Whether the container is a section.
*
* @return {Promise<string>} The widget ID.
*/
async addWidget(
widgetType: string,
container: string = null,
isContainerASection: boolean = false,
): Promise<string> {
const widgetId = await this.addElement(
{ widgetType, elType: 'widget' },
container,
isContainerASection,
);
await this.getPreviewFrame().waitForSelector(`[data-id='${widgetId}']`);
return widgetId;
}
/**
* Add a page by importing a Json page object from PostMeta _elementor_data into Tests
*
* @param {string} dirName - Directory name, use `__dirname` for the current directory.
* @param {string} fileName - Name of the file without extension.
* @param {string} widgetSelector - Selector of the widget.
* @param {boolean} updateDatesForImages - Optional. Whether to update image dates in the template. Default is false.
*
* @return {Promise<void>}
*/
async loadJsonPageTemplate(
dirName: string,
fileName: string,
widgetSelector: string,
updateDatesForImages: boolean = false,
): Promise<void> {
const filePath = _path.resolve(dirName, `./templates/${fileName}.json`);
const rawFileData = await readFile(filePath);
const templateData = JSON.parse(rawFileData.toString());
const pageTemplateData = {
content: templateData,
page_settings: [],
version: '0.4',
title: 'Elementor Test',
type: 'page',
};
// For templates that use images, date when image is uploaded is hardcoded in template.
// Element regression tests upload images before each test.
// To update dates in template, use a flag updateDatesForImages = true
if (updateDatesForImages) {
this.updateImageDates(templateData);
}
await this.page.evaluate((data) => {
const model = new Backbone.Model({ title: 'test' });
window.$e.run('document/elements/import', {
data,
model,
options: {
at: 0,
withPageSettings: false,
},
});
}, pageTemplateData);
await this.waitForElement(false, widgetSelector);
}
/**
* Get element handle from the preview frame using its Container ID.
*
* @param {string} id - Container ID.
*
* @return {Promise<ElementHandle<SVGElement | HTMLElement> | null>} element handle
*/
async getElementHandle(
id: string,
): Promise<ElementHandle<SVGElement | HTMLElement> | null> {
return this.getPreviewFrame().$(getElementSelector(id));
}
async waitForPreviewFrame(): Promise<Frame> {
await this.page.waitForSelector('[id="elementor-preview-iframe"]', {
timeout: timeouts.longAction,
});
const frame = this.page.frame({ name: 'elementor-preview-iframe' });
if (!frame) {
throw new Error('Iframe is null even after it appeared in the DOM.');
}
return frame;
}
/**
* Get the frame of the Elementor editor preview.
*
* @return {Frame} The preview iframe element.
*/
getPreviewFrame(): Frame {
return this.page.frame({ name: 'elementor-preview-iframe' });
}
/**
* Select an element inside the editor.
*
* @param {string} elementId - Element ID.
*
* @return {Promise<Locator>} element;
*/
async selectElement(elementId: string): Promise<Locator> {
await this.page.evaluate(
({ id }) => {
$e.run('document/elements/select', {
container: elementor.getContainer(id),
});
},
{ id: elementId },
);
await this.getPreviewFrame().waitForSelector(
'.elementor-element-' + elementId + '.elementor-element-editable',
);
return this.getPreviewFrame().locator('.elementor-element-' + elementId);
}
/**
* Add new container preset.
*
* @param {ContainerType} element - Element type. Available values: 'flex', 'grid'.
* @param {ContainerPreset} preset - Container preset.
*
* @return {Promise<void>}
*/
async addNewContainerPreset(
element: ContainerType,
preset: ContainerPreset,
): Promise<void> {
const frame = this.getPreviewFrame();
await frame.locator('.elementor-add-section-button').click();
await frame.locator(`.${element}-preset-button`).click();
await frame.locator(`[data-preset=${preset}]`).click();
}
/**
* Open the section that adds a new element.
*
* @param {string} elementId - Element ID.
*
* @return {Promise<void>}
*/
async openAddElementSection(elementId: string): Promise<void> {
const element = this.getPreviewFrame().locator(
`.elementor-edit-mode .elementor-element-${elementId}`,
);
await element.hover();
const elementAddButton = this.getPreviewFrame().locator(
`.elementor-edit-mode .elementor-element-${elementId} > .elementor-element-overlay > .elementor-editor-element-settings > .elementor-editor-element-add`,
);
await elementAddButton.click();
await this.getPreviewFrame().waitForSelector(
'.elementor-add-section-inline',
);
}
async setWidgetTab(tab: 'content' | 'style' | 'advanced'): Promise<void> {
await this.page.locator(`.elementor-tab-control-${tab}`).click();
}
/**
* Open a tab inside an Editor panel.
*
* @param {string} panelId - The panel tab to open.
*
* @return {Promise<void>}
*/
async openPanelTab(panelId: string): Promise<void> {
await this.page.waitForSelector(`.elementor-tab-control-${panelId} span`);
// Check if panel has been activated already.
if (
await this.page.$(`.elementor-tab-control-${panelId}.elementor-active`)
) {
return;
}
await this.page.locator(`.elementor-tab-control-${panelId} span`).click();
await this.page.waitForSelector(
`.elementor-tab-control-${panelId}.elementor-active`,
);
}
/**
* Open a tab inside an Editor panel for V2 widgets.
*
* @param {'style' | 'general'} sectionName - The section to open.
*
* @return {Promise<void>}
*/
async openV2PanelTab(sectionName: 'style' | 'general') {
const selectorMap: Record<'style' | 'general', string> = {
style: 'style',
general: 'settings',
};
const sectionButtonSelector = `#tab-0-${selectorMap[sectionName]}`,
sectionContentSelector = `#tabpanel-0-${selectorMap[sectionName]}`,
isOpenSection = await this.page.evaluate((selector) => {
const sectionContentElement: HTMLElement =
document.querySelector(selector);
return !sectionContentElement?.hidden;
}, sectionContentSelector);
if (isOpenSection) {
return;
}
await this.page.locator(sectionButtonSelector).click();
await this.page.locator(sectionContentSelector).waitFor();
}
/**
* Open a section in an active panel tab.
*
* @param {string} sectionId - The section to open.
*
* @return {Promise<void>}
*/
async openSection(sectionId: string): Promise<void> {
const sectionSelector = `.elementor-control-${sectionId}`,
isOpenSection = await this.page.evaluate((selector) => {
const sectionElement = document.querySelector(selector);
return (
sectionElement?.classList.contains('e-open') ||
sectionElement?.classList.contains('elementor-open')
);
}, sectionSelector),
section = await this.page.$(
sectionSelector + ':not( .e-open ):not( .elementor-open ):visible',
);
if (!section || isOpenSection) {
return;
}
await this.page
.locator(
sectionSelector +
':not( .e-open ):not( .elementor-open ):visible' +
' .elementor-panel-heading',
)
.click();
}
/**
* Close a section in an active panel tab.
*
* @param {string} sectionId - The section to close.
*
* @return {Promise<void>}
*/
async closeSection(sectionId: string): Promise<void> {
const sectionSelector = `.elementor-control-${sectionId}`,
isOpenSection = await this.page.evaluate((selector) => {
const sectionElement = document.querySelector(selector);
return (
sectionElement?.classList.contains('e-open') ||
sectionElement?.classList.contains('elementor-open')
);
}, sectionSelector),
section = await this.page.$(sectionSelector + '.e-open:visible');
if (!section || !isOpenSection) {
return;
}
await this.page
.locator(sectionSelector + '.e-open:visible .elementor-panel-heading')
.click();
}
/**
* Open a section in an active panel tab.
*
* @param {string} sectionId - The section to open.
*
* @return {Promise<void>}
*/
async openV2Section(
sectionId:
| 'layout'
| 'spacing'
| 'size'
| 'position'
| 'typography'
| 'background'
| 'border',
) {
const sectionButton = this.page.locator('.MuiButtonBase-root', {
hasText: new RegExp(sectionId, 'i'),
});
const contentSelector = await sectionButton.getAttribute('aria-controls');
const isContentVisible = await this.page.evaluate((selector) => {
return !!document.getElementById(selector);
}, contentSelector);
if (isContentVisible) {
return;
}
await sectionButton.click();
}
/**
* Set a custom width value to a widget.
*
* @param {string} width - Optional. The custom width value (as a percentage). Default is '100'.
*
* @return {Promise<void>}
*/
async setWidgetCustomWidth(width: string = '100'): Promise<void> {
await this.openPanelTab('advanced');
await this.setSelectControlValue('_element_width', 'initial');
await this.setSliderControlValue('_element_custom_width', width);
}
/**
* Set tab control value.
*
* @param {string} controlId - The control to select.
* @param {string} tabId - The tab to select.
*
* @return {Promise<void>}
*/
async setTabControlValue(controlId: string, tabId: string): Promise<void> {
await this.page
.locator(`.elementor-control-${controlId} .elementor-control-${tabId}`)
.first()
.click();
}
/**
* Set text control value.
*
* @param {string} controlId - The control to set the value to.
* @param {string} value - The value to set.
*
* @return {Promise<void>}
*/
async setTextControlValue(controlId: string, value: string): Promise<void> {
await this.page
.locator(`.elementor-control-${controlId} input`)
.nth(0)
.fill(value.toString());
}
/**
* Set textarea control value.
*
* @param {string} controlId - The control to set the value to.
* @param {string} value - The value to set.
*
* @return {Promise<void>}
*/
async setTextareaControlValue(
controlId: string,
value: string,
): Promise<void> {
await this.page
.locator(`.elementor-control-${controlId} textarea`)
.fill(value.toString());
}
/**
* Set number control value.
*
* @param {string} controlId - The control to set the value to.
* @param {string} value - The value to set.
*
* @return {Promise<void>}
*/
async setNumberControlValue(controlId: string, value: string): Promise<void> {
await this.page
.locator(`.elementor-control-${controlId} input >> nth=0`)
.fill(value.toString());
}
/**
* Set slider control value.
*
* @param {string} controlId - The control to set the value to.
* @param {string} value - The value to set.
*/
async setSliderControlValue(controlId: string, value: string): Promise<void> {
await this.page
.locator(`.elementor-control-${controlId} .elementor-slider-input input`)
.fill(value);
}
/**
* Set select control value.
*
* @param {string} controlId - The control to set the value to.
* @param {string} value - The value to set.
*
* @return {Promise<void>}
*/
async setSelectControlValue(controlId: string, value: string): Promise<void> {
await this.page.selectOption(
`.elementor-control-${controlId} select`,
value,
);
}
/**
* Set select2 control value.
*
* @param {string} controlId - The control to set the value to.
* @param {string} value - The value to set.
* @param {boolean} exactMatch - Optional. Select only items that exactly match the provided value. Default is true.
*
* @return {Promise<void>}
*/
async setSelect2ControlValue(
controlId: string,
value: string,
exactMatch: boolean = true,
): Promise<void> {
await this.page
.locator(
`.elementor-control-${controlId} .select2:not( .select2-container--disabled )`,
)
.click();
await this.page
.locator('.select2-search--dropdown input[type="search"]')
.fill(value);
if (exactMatch) {
await this.page
.locator(`.select2-results__option:text-is("${value}")`)
.first()
.click();
} else {
await this.page
.locator(`.select2-results__option:has-text("${value}")`)
.first()
.click();
}
await this.page.waitForLoadState('domcontentloaded');
}
/**
* Set dimensions control value.
*
* @param {string} controlId - The control to set the value to.
* @param {string} value - The value to set.
*
* @return {Promise<void>}
*/
async setDimensionsValue(controlId: string, value: string): Promise<void> {
await this.page
.locator(
`.elementor-control-${controlId} .elementor-control-dimensions li:first-child input`,
)
.fill(value);
}
/**
* Set choose control value.
*
* TODO: For consistency, we need to rewrite the logic, from icon based to value based.
*
* @param {string} controlId - The control to set the value to.
* @param {string} icon - The icon to choose.
*
* @return {Promise<void>}
*/
async setChooseControlValue(controlId: string, icon: string): Promise<void> {
await this.page.locator(`.elementor-control-${controlId} .${icon}`).click();
}
/**
* Set color control value.
*
* @param {string} controlId - The control to set the value to.
* @param {string} value - The value to set.
*
* @return {Promise<void>}
*/
async setColorControlValue(controlId: string, value: string): Promise<void> {
const controlSelector = `.elementor-control-${controlId}`;
await this.page.locator(controlSelector + ' .pcr-button').click();
await this.page
.locator('.pcr-app.visible .pcr-interaction input.pcr-result')
.fill(value);
await this.page.locator(controlSelector).click();
}
/**
* Set switcher control value.
*
* @param {string} controlId - The control to set the value to.
* @param {boolean} value - Optional. The value to set (true|false). Default is true.
*
* @return {Promise<void>}
*/
async setSwitcherControlValue(
controlId: string,
value: boolean = true,
): Promise<void> {
const controlSelector = `.elementor-control-${controlId}`,
controlLabel = this.page.locator(
controlSelector + ' label.elementor-switch',
),
currentState = await this.page
.locator(controlSelector + ' input[type="checkbox"]')
.isChecked();
if (currentState !== Boolean(value)) {
await controlLabel.click();
}
}
/**
* Set gap control value.
*
* @param {string} controlId - The control to set the value to.
* @param {GapControl} value - The value to set. Either a string or an object with column, row and unit values.
*
* @return {Promise<void>}
*/
async setGapControlValue(
controlId: string,
value: GapControl,
): Promise<void> {
const control = this.page.locator(`.elementor-control-${controlId}`);
if ('string' === typeof value) {
await control
.locator('.elementor-control-gap >> nth=0')
.locator('input')
.fill(value);
} else if ('object' === typeof value) {
await control.locator('.elementor-link-gaps').click();
await control
.locator('.elementor-control-gap input[data-setting="column"]')
.fill(value.column);
await control
.locator('.elementor-control-gap input[data-setting="row"]')
.fill(value.row);
if (value.unit) {
await control.locator('.e-units-switcher').click();
await control.locator(`[data-choose="${value.unit}"]`).click();
}
}
}
/**
* Set an image on a media control.
*
* @param {string} controlId - The control to set the value to.
* @param {boolean} imageTitle - The title of the image to set.
*
* @return {Promise<void>}
*/
async setMediaControlImageValue(
controlId: string,
imageTitle: string,
): Promise<void> {
await this.page
.locator(
`.elementor-control-${controlId} .elementor-control-media__preview`,
)
.click();
await this.page.getByRole('tab', { name: 'Media Library' }).click();
await this.page.locator(`[aria-label="${imageTitle}"]`).click();
await this.page.locator('.button.media-button').click();
}
/**
* Set typography control value.
*
* @param {string} controlId - The control to set the value to.
* @param {string} fontsize - Font size value.
*
* @return {Promise<void>}
*/
async setTypographyControlValue(
controlId: string,
fontsize: string,
): Promise<void> {
const controlSelector = `.elementor-control-${controlId}_typography .eicon-edit`;
await this.page.locator(controlSelector).click();
await this.setSliderControlValue(controlId + '_font_size', fontsize);
await this.page.locator(controlSelector).click();
}
async setShadowControlValue(
controlId: string,
shadowType: string,
): Promise<void> {
await this.page
.locator(
`.elementor-control-${controlId}_${shadowType}_shadow_type i.eicon-edit`,
)
.click();
await this.page
.locator(
`.elementor-control-${controlId}_${shadowType}_shadow_type label`,
)
.first()
.click();
}
async setTextStrokeControlValue(
controlId: string,
strokeType: string,
value: number,
color: string,
): Promise<void> {
await this.page
.locator(
`.elementor-control-${controlId}_${strokeType}_stroke_type i.eicon-edit`,
)
.click();
await this.page
.locator(
`.elementor-control-${controlId}_${strokeType}_stroke input[type="number"]`,
)
.first()
.fill(value.toString());
await this.page
.locator(`.elementor-control-${controlId}_stroke_color .pcr-button`)
.first()
.click();
await this.page.locator('.pcr-app.visible .pcr-result').first().fill(color);
await this.page
.locator(
`.elementor-control-${controlId}_${strokeType}_stroke_type label`,
)
.first()
.click();
}
async setWidgetMask(): Promise<void> {
await this.openSection('_section_masking');
await this.setSwitcherControlValue('_mask_switch', true);
await this.setSelectControlValue('_mask_size', 'custom');
await this.setSliderControlValue('_mask_size_scale', '30');
await this.setSelectControlValue('_mask_position', 'top right');
}
/**
* Hide controls from the video widgets.
*
* @return {Promise<void>}
*/
async hideVideoControls(): Promise<void> {
await this.getPreviewFrame().waitForSelector('.elementor-video');
const videoFrame = this.getPreviewFrame().frameLocator('.elementor-video'),
videoButton = videoFrame.locator(
'button.ytp-large-play-button.ytp-button.ytp-large-play-button-red-bg',
),
videoGradient = videoFrame.locator('.ytp-gradient-top'),
videoTitle = videoFrame.locator('.ytp-show-cards-title'),
videoBottom = videoFrame.locator('.ytp-impression-link');
await videoButton.evaluate((element) => (element.style.opacity = '0'));
await videoGradient.evaluate((element) => (element.style.opacity = '0'));
await videoTitle.evaluate((element) => (element.style.opacity = '0'));
await videoBottom.evaluate((element) => (element.style.opacity = '0'));
}
/**
* Hide controls and overlays on map widgets.
*
* @return {Promise<void>}
*/
async hideMapControls(): Promise<void> {
await this.getPreviewFrame().waitForSelector(
'.elementor-widget-google_maps iframe',
);
const mapFrame = this.getPreviewFrame().frameLocator(
'.elementor-widget-google_maps iframe',
),
mapText = mapFrame.locator('.gm-style iframe + div + div'),
mapInset = mapFrame.locator('button.gm-inset-map.gm-inset-light'),
mapControls = mapFrame.locator(
'.gmnoprint.gm-bundled-control.gm-bundled-control-on-bottom',
);
await mapText.evaluate((element) => (element.style.opacity = '0'));
await mapInset.evaluate((element) => (element.style.opacity = '0'));
await mapControls.evaluate((element) => (element.style.opacity = '0'));
}
/**
* Open the page in the Preview mode.
*
* @return {Promise<void>}
*/
async togglePreviewMode(): Promise<void> {
if (!(await this.page.$('body.elementor-editor-preview'))) {
await this.page.locator('#elementor-mode-switcher').click();
await this.page.waitForSelector('body.elementor-editor-preview');
await this.page.waitForTimeout(500);
} else {
await this.page.locator('#elementor-mode-switcher-preview').click();
await this.page.waitForSelector('body.elementor-editor-active');
}
}
/**
* Wait for the Elementor preview to finish loading.
*
* @return {Promise<void>}
*/
async waitForPreviewToLoad(): Promise<void> {
await this.page.waitForSelector('#elementor-preview-loading');
await this.page.waitForSelector('#elementor-preview-loading', {
state: 'hidden',
});
}
/**
* Hide all editor elements from the screenshots.
*
* @return {Promise<void>}
*/
async hideEditorElements(): Promise<void> {
const css =
'<style>.elementor-element-overlay,.elementor-empty-view{opacity: 0;}.elementor-widget,.elementor-widget:hover{box-shadow:none!important;}</style>';
await this.addWidget('html');
await this.setTextareaControlValue('type-code', css);
}
/**
* Whether the Top Bar is active or not.
*
* @return {Promise<boolean>} Returns true if the Top Bar is visible, false otherwise.
*/
async hasTopBar(): Promise<boolean> {
return await this.page
.locator(EditorSelectors.panels.topBar.wrapper)
.isVisible();
}
/**
* Click on a top bar item.
*
* @param {TopBarSelector} selector - The selector object for the top bar button.
*
* @return {Promise<void>}
*/
async clickTopBarItem(selector: TopBarSelector): Promise<void> {
const topbarLocator = this.page.locator(
EditorSelectors.panels.topBar.wrapper,
);
if ('text' === selector.attribute) {
await topbarLocator
.getByRole('button', { name: selector.attributeValue })
.click();
} else {
await topbarLocator
.locator(`button[${selector.attribute}="${selector.attributeValue}"]`)
.click();
}
}
/**
* Open the menu panel. Or, when an inner panel is provided, open the inner panel.
*
* TODO: Delete when Editor Top Bar feature is merged.
*
* @param {string} innerPanel - Optional. The inner menu to open.
*
* @return {Promise<void>}
*/
async openMenuPanel(innerPanel?: string): Promise<void> {
await this.page.locator(EditorSelectors.panels.menu.footerButton).click();
await this.page.locator(EditorSelectors.panels.menu.wrapper).waitFor();
if (innerPanel) {
await this.page
.locator(`.elementor-panel-menu-item-${innerPanel}`)
.click();
}
}
/**
* Open the elements/widgets panel.
*
* @return {Promise<void>}
*/
async openElementsPanel(): Promise<void> {
const hasTopBar = await this.hasTopBar();
if (hasTopBar) {
await this.clickTopBarItem(TopBarSelectors.elementsPanel);
} else {
await this.page
.locator(EditorSelectors.panels.elements.footerButton)
.click();
}
await this.page.locator(EditorSelectors.panels.elements.wrapper).waitFor();
}
/**
* Open the page settings panel.
*
* @return {Promise<void>}
*/
async openPageSettingsPanel(): Promise<void> {
const hasTopBar = await this.hasTopBar();
if (hasTopBar) {
await this.clickTopBarItem(TopBarSelectors.documentSettings);
} else {
await this.page
.locator(EditorSelectors.panels.pageSettings.footerButton)
.click();
}
await this.page
.locator(EditorSelectors.panels.pageSettings.wrapper)
.waitFor();
}
/**
* Open the site settings panel. Or, when an inner panel is provided, open the inner panel.
*
* @param {string} innerPanel - Optional. The inner menu to open.
*
* @return {Promise<void>}
*/
async openSiteSettings(innerPanel?: string): Promise<void> {
const hasTopBar = await this.hasTopBar();
if (hasTopBar) {
await this.clickTopBarItem(TopBarSelectors.siteSettings);
} else {
await this.openMenuPanel('global-settings');
}
await this.page
.locator(EditorSelectors.panels.siteSettings.wrapper)
.waitFor();
if (innerPanel) {
await this.page
.locator(`.elementor-panel-menu-item-${innerPanel}`)
.click();
}
}
/**
* Open the user preferences panel.
*
* @return {Promise<void>}
*/
async openUserPreferencesPanel(): Promise<void> {
const hasTopBar = await this.hasTopBar();
if (hasTopBar) {
await this.clickTopBarItem(TopBarSelectors.elementorLogo);
await this.page.waitForTimeout(100);
await this.page
.getByRole('menuitem', { name: 'User Preferences' })
.click();
} else {
await this.openMenuPanel('editor-preferences');
}
await this.page
.locator(EditorSelectors.panels.userPreferences.wrapper)
.waitFor();
}
/**
* Close the navigator/structure panel.
*
* @return {Promise<void>}
*/
async closeNavigatorIfOpen(): Promise<void> {
await this.waitForPreviewFrame();
const isOpen = await this.getPreviewFrame().evaluate(() =>
elementor.navigator.isOpen(),
);
if (!isOpen) {
return;
}
await this.page
.locator(EditorSelectors.panels.navigator.closeButton)
.click();
}
/**
* Set WordPress page template.
*
* @param {string} template - The page template to set. Available options: 'default', 'canvas', 'full-width'.
*
* @return {Promise<void>}
*/
async setPageTemplate(
template: 'default' | 'canvas' | 'full-width',
): Promise<void> {
let templateValue: string;
let templateClass: string;
switch (template) {
case 'default':
templateValue = 'default';
templateClass = '.elementor-default';
break;
case 'canvas':
templateValue = 'elementor_canvas';
templateClass = '.elementor-template-canvas';
break;
case 'full-width':
templateValue = 'elementor_header_footer';
templateClass = '.elementor-template-full-width';
break;
}
// Check if the template is already set
if (await this.getPreviewFrame().$(templateClass)) {
return;
}
// Select the template
await this.openPageSettingsPanel();
await this.setSelectControlValue('template', templateValue);
await this.getPreviewFrame().waitForSelector(templateClass);
}
/**
* Change the display mode of the editor.
*
* @param {string} uiMode - Either 'light', 'dark', or 'auto'.
*
* @return {Promise<void>}
*/
async setDisplayMode(uiMode: string): Promise<void> {
const uiThemeOptions = {
light: 'eicon-light-mode',
dark: 'eicon-dark-mode',
auto: 'eicon-header',
};
await this.openUserPreferencesPanel();
await this.setChooseControlValue('ui_theme', uiThemeOptions[uiMode]);
}
/**
* Open the responsive view bar.
*
* TODO: Delete when Editor Top Bar feature is merged.
*
* @return {Promise<void>}
*/
async openResponsiveViewBar(): Promise<void> {
const hasResponsiveViewBar = await this.page.evaluate(() =>
elementor.isDeviceModeActive(),
);
if (!hasResponsiveViewBar) {
await this.page.locator('#elementor-panel-footer-responsive i').click();
}
}
/**
* Select a responsive view.
*
* @param {Device} device - The name of the device breakpoint, such as `tablet_extra`.
*
* @return {Promise<void>}
*/
async changeResponsiveView(device: Device): Promise<void> {
const hasTopBar = await this.hasTopBar();
if (hasTopBar) {
await Breakpoints.getDeviceLocator(this.page, device).click();
} else {
await this.openResponsiveViewBar();
await this.page
.locator(`#e-responsive-bar-switcher__option-${device}`)
.first()
.locator('i')
.click();
}
}
/**
* Publish the current page.
*
* @return {Promise<void>}
*/
async publishPage(): Promise<void> {
const hasTopBar = await this.hasTopBar();
if (hasTopBar) {
await this.clickTopBarItem(TopBarSelectors.publish);
await this.page.waitForLoadState();
await this.page
.locator(EditorSelectors.panels.topBar.wrapper + ' button[disabled]', {
hasText: 'Publish',
})
.waitFor({ timeout: timeouts.longAction });
} else {
await this.page
.locator('button#elementor-panel-saver-button-publish')
.click();
await this.page.waitForLoadState();
await this.page.getByRole('button', { name: 'Update' }).waitFor();
}
}
/**
* Publish the current page and view it.
*
* @return {Promise<void>}
*/
async publishAndViewPage(): Promise<void> {
const hasTopBar = await this.hasTopBar();
await this.publishPage();
if (hasTopBar) {
await this.clickTopBarItem(TopBarSelectors.saveOptions);
await this.page.getByRole('menuitem', { name: 'View Page' }).click();
const pageId = await this.getPageId();
await this.page.goto(`/?p=${pageId}`);
} else {
await this.openMenuPanel('view-page');
}
await this.page.waitForLoadState();
}
async viewPage() {
const pageId = await this.getPageId();
await this.page.goto(`/?p=${pageId}`);
await this.page.waitForLoadState();
}
/**
* Save and reload the current page.
*
* @return {Promise<void>}
*/
async saveAndReloadPage(): Promise<void> {
const hasTopBar = await this.hasTopBar();
if (hasTopBar) {
await this.clickTopBarItem(TopBarSelectors.publish);
} else {
await this.page.locator('#elementor-panel-saver-button-publish').click();
}
await this.page.waitForLoadState();
await this.page.waitForResponse('/wp-admin/admin-ajax.php');
await this.page.reload();
}
/**
* Get the current page ID.
*
* @return {Promise<string>} The ID of the current page.
*/
async getPageId(): Promise<string> {
return await this.page.evaluate(() => elementor.config.initialDocument.id);
}
/**
* Apply Element Settings
*
* Apply settings to a widget without having to navigate through its Panels and Sections to set each individual
* control value.
*
* You can get the Element settings by right-clicking an existing widget or element in the Editor, choose "Copy",
* then paste the content into a text editor and filter out just the settings you want to apply to your element.
*
* Example usage:
* ```
* await editor.applyElementSettings( 'cdefd82', {
* background_background: 'classic',
* background_color: 'rgb(255, 10, 10)',
* } );
* ```
*
* @param {string} elementId - Id of the element you intend to apply the settings to.
* @param {Object} settings - Object settings from the Editor > choose element > right-click > "Copy".
*
* @return {Promise<void>}
*/
async applyElementSettings(
elementId: string,
settings: unknown,
): Promise<void> {
await this.page.evaluate(
(args) =>
$e.run('document/elements/settings', {
container: elementor.getContainer(args.elementId),
settings: args.settings,
}),
{ elementId, settings },
);
}
/**
* Check if an item is in the viewport.
*
* @param {string} itemSelector - The item selector.
*
* @return {Promise<boolean>} Returns true if the item is in the viewport, false otherwise.
*/
async isItemInViewport(itemSelector: string): Promise<boolean> {
return this.page.evaluate((item: string) => {
let isVisible = false;
const element: HTMLElement = document.querySelector(item);
if (element) {
const rect = element.getBoundingClientRect();
if (rect.top >= 0 && rect.left >= 0) {
const vw = Math.max(
document.documentElement.clientWidth || 0,
window.innerWidth || 0,
),
vh = Math.max(
document.documentElement.clientHeight || 0,
window.innerHeight || 0,
);
if (rect.right <= vw && rect.bottom <= vh) {
isVisible = true;
}
}
}
return isVisible;
}, itemSelector);
}
/**
* Get the number of widgets in the editor.
*
* @return {Promise<number>} The number of widgets in the editor.
*/
async getWidgetCount(): Promise<number> {
return (await this.getPreviewFrame().$$(EditorSelectors.widget)).length;
}
/**
* Based on the widget type, wait for the iframe to load.
*
* @param {string} widgetType - The widget type. Available options: 'video', 'google_maps', 'sound_cloud'.
* @param {boolean} isPublished - Optional. Whether the element is published. Default is false.
*
* @return {Promise<void>}
*/
async waitForIframeToLoaded(
widgetType: string,
isPublished: boolean = false,
): Promise<void> {
const frames = {
video: [EditorSelectors.video.iframe, EditorSelectors.video.playIcon],
google_maps: [
EditorSelectors.googleMaps.iframe,
EditorSelectors.googleMaps.showSatelliteViewBtn,
],
sound_cloud: [
EditorSelectors.soundCloud.iframe,
EditorSelectors.soundCloud.waveForm,
],
};
if (!(widgetType in frames)) {
return;
}
if (isPublished) {
await this.page.locator(frames[widgetType][0]).first().waitFor();
const count = await this.page.locator(frames[widgetType][0]).count();
for (let i = 1; i < count; i++) {
await this.page
.frameLocator(frames[widgetType][0])
.nth(i)
.locator(frames[widgetType][1])
.waitFor();
}
} else {
const frame = this.getPreviewFrame();
await frame.waitForLoadState();
await frame.waitForSelector(frames[widgetType][0]);
await frame
.frameLocator(frames[widgetType][0])
.first()
.locator(frames[widgetType][1])
.waitFor();
const iframeCount: number = await new Promise((resolved) => {
resolved(frame.childFrames().length);
});
for (let i = 1; i < iframeCount; i++) {
await frame
.frameLocator(frames[widgetType][0])
.nth(i)
.locator(frames[widgetType][1])
.waitFor();
}
}
}
/**
* Wait for the element to be visible.
*
* @param {boolean} isPublished - Whether the element is published.
* @param {string} selector - Element selector.
*
* @return {Promise<void>}
*/
async waitForElement(isPublished: boolean, selector: string): Promise<void> {
if (selector === undefined) {
return;
}
if (isPublished) {
await this.page.waitForSelector(selector);
} else {
const frame = this.getPreviewFrame();
await frame.waitForLoadState();
await frame.waitForSelector(selector);
}
}
/**
* Verify class in element.
*
* @param {Object} args - Arguments.
* @param {string} args.selector - Element selector.
* @param {string} args.className - Class name.
* @param {boolean} args.isPublished - Whether the element is published.
*
* @return {Promise<void>}
*/
async verifyClassInElement(args: {
selector: string;
className: string;
isPublished: boolean;
}): Promise<void> {
const regex = new RegExp(args.className);
if (args.isPublished) {
await expect(this.page.locator(args.selector)).toHaveClass(regex);
} else {
await expect(this.getPreviewFrame().locator(args.selector)).toHaveClass(
regex,
);
}
}
/**
* Verify image size.
*
* @param {Object} args - Arguments.
* @param {string} args.selector - Element selector.
* @param {number} args.width - Image width.
* @param {number} args.height - Image height.
* @param {boolean} args.isPublished - Whether the element is published.
*
* @return {Promise<void>}
*/
async verifyImageSize(args: {
selector: string;
width: number;
height: number;
isPublished: boolean;
}): Promise<void> {
const imageSize = args.isPublished
? await this.page.locator(args.selector).boundingBox()
: await this.getPreviewFrame().locator(args.selector).boundingBox();
expect(imageSize.width).toEqual(args.width);
expect(imageSize.height).toEqual(args.height);
}
/**
* Checks for a stable UI state by comparing screenshots at intervals and expecting a match.
* Can be used to check for completed rendering. Useful to wait out animations before screenshots and expects.
* Should be less flaky than waitForLoadState( 'load' ) in editor where Ajax re-rendering is triggered.
*
* @param {Locator} locator - The locator to check for.
* @param {number} retries - Optional. Number of retries. Default is 3.
* @param {number} timeout - Optional. Time to wait between retries, in milliseconds. Default is 500.
*
* @return {Promise<void>}
*/
async isUiStable(
locator: Locator,
retries: number = 3,
timeout: number = 500,
): Promise<void> {
const comparator = getComparator('image/png');
let retry = 0,
beforeImage: Buffer,
afterImage: Buffer;
await locator.waitFor();
do {
if (retry === retries) {
break;
}
beforeImage = await locator.screenshot({
path: `./before.png`,
});
await new Promise((resolve) => setTimeout(resolve, timeout));
afterImage = await locator.screenshot({
path: `./after.png`,
});
retry = retry++;
} while (null !== comparator(beforeImage, afterImage));
}
/**
* Remove classes from the page.
*
* @param {string} className - The class to remove.
*
* @return {Promise<void>}
*/
async removeClasses(className: string): Promise<void> {
await this.page.evaluate(async (_class) => {
await new Promise((resolve1) => {
const elems = document.querySelectorAll(`.${_class}`);
[].forEach.call(elems, function (el: HTMLElement) {
el.classList.remove(_class);
});
resolve1('Foo');
});
}, className);
}
/**
* Scroll the page.
*
* @return {Promise<void>}
*/
async scrollPage(): Promise<void> {
await this.page.evaluate(async () => {
await new Promise((resolve1) => {
let totalHeight = 0;
const distance = 400;
const timer = setInterval(() => {
const scrollHeight = document.body.scrollHeight;
window.scrollBy(0, distance);
totalHeight += distance;
if (totalHeight >= scrollHeight) {
clearInterval(timer);
window.scrollTo(0, 0);
resolve1('Foo');
}
}, 100);
});
});
}
/**
* Remove the WordPress admin bar.
*
* @return {Promise<void>}
*/
async removeWpAdminBar(): Promise<void> {
const adminBar = 'wpadminbar';
await this.page
.locator(`#${adminBar}`)
.waitFor({ timeout: timeouts.longAction });
await this.page.evaluate((selector) => {
const admin = document.getElementById(selector);
admin.remove();
}, adminBar);
}
/**
* Isolated ID number.
*
* @param {string} idPrefix - The prefix of the item.
* @param {string} itemID - The item ID.
*
* @return {Promise<number>} The numeric part of the ID with the prefix removed.
*/
async isolatedIdNumber(idPrefix: string, itemID: string): Promise<number> {
return Number(itemID.replace(idPrefix, ''));
}
async addImagesToGalleryControl(args?: {
images?: string[];
metaData?: boolean;
}) {
const defaultImages = ['A.jpg', 'B.jpg', 'C.jpg', 'D.jpg', 'E.jpg'];
await this.page
.locator(EditorSelectors.galleryControl.addGalleryBtn)
.nth(0)
.click();
await this.page.getByRole('tab', { name: 'Media Library' }).click();
const _images = args?.images === undefined ? defaultImages : args.images;
for (const i in _images) {
await this.page.setInputFiles(
EditorSelectors.media.imageInp,
pathResolve(__dirname, `../resources/${_images[i]}`),
);
if (args?.metaData) {
await this.addTestImageMetaData();
}
}
await this.page.locator(EditorSelectors.media.addGalleryButton).click();
await this.page.locator('text=Insert gallery').click();
}
async addTestImageMetaData(
args = { caption: 'Test caption!', description: 'Test description!' },
) {
await this.page.locator(EditorSelectors.media.images).first().click();
await this.page.locator(EditorSelectors.media.imgCaption).clear();
await this.page
.locator(EditorSelectors.media.imgCaption)
.type(args.caption);
await this.page.locator(EditorSelectors.media.images).first().click();
await this.page.locator(EditorSelectors.media.imgDescription).clear();
await this.page
.locator(EditorSelectors.media.imgDescription)
.type(args.description);
}
/**
* Save the site settings with the top bar.
*
* TODO: Rename when Editor Top Bar feature is merged.
*
* @param {boolean} toReload - Whether to reload the page after saving.
*
* @return {Promise<void>}
*/
async saveSiteSettingsWithTopBar(toReload: boolean): Promise<void> {
if (
await this.page
.locator(EditorSelectors.panels.siteSettings.saveButton)
.isEnabled()
) {
await this.page
.locator(EditorSelectors.panels.siteSettings.saveButton)
.click();
} else {
await this.page.evaluate((selector) => {
const button: HTMLElement = document.evaluate(
selector,
document,
null,
XPathResult.FIRST_ORDERED_NODE_TYPE,
).singleNodeValue as HTMLElement;
button.click();
}, EditorSelectors.panels.siteSettings.saveButton);
}
if (toReload) {
await this.page
.locator(EditorSelectors.refreshPopup.reloadButton)
.click();
}
}
/**
* Save the site settings without the top bar.
*
* TODO: Delete when Editor Top Bar feature is merged.
*
* @return {Promise<void>}
*/
async saveSiteSettingsNoTopBar(): Promise<void> {
await this.page
.locator(EditorSelectors.panels.footerTools.updateButton)
.click();
await this.page.locator(EditorSelectors.toast).waitFor();
}
async assertCorrectVwWidthStylingOfElement(
element: Locator,
vwValue: number = 100,
): Promise<void> {
const viewport = this.page.viewportSize();
const vwConvertedToPxUnit = (viewport.width * vwValue) / 100;
const elementWidthInPxUnit = await element
.boundingBox()
.then((box) => box?.width ?? 0);
const vwAndPxValuesAreEqual =
Math.abs(vwConvertedToPxUnit - elementWidthInPxUnit) <= 1;
expect(vwAndPxValuesAreEqual).toBeTruthy();
}
}