diff --git a/backstop.json b/backstop.json index 4f94cd8..98d6be4 100644 --- a/backstop.json +++ b/backstop.json @@ -7,8 +7,7 @@ "height": 1080 } ], - "scenarios": [ - ], + "scenarios": [], "paths": { "bitmaps_reference": "backstop_data/bitmaps_reference", "bitmaps_test": "backstop_data/bitmaps_test", diff --git a/config/wp.config.sample.ts b/config/wp.config.sample.ts index 42ca744..ac9b711 100755 --- a/config/wp.config.sample.ts +++ b/config/wp.config.sample.ts @@ -1,3 +1,4 @@ +import ScenarioUrls from "./scenarioUrls.json"; /** * The default WordPress admin user configuration for both local and live environments. * @constant @@ -15,6 +16,16 @@ const WP_ADMIN_USER = { } as const; +/** + * The default Imagify settings information + * + * @constant + * @type {{ apiKey: string }} + */ +const IMAGIFY_INFOS = { + apiKey: '' +} as const; + /** * Extracted environment variables related to WordPress configuration. * Uses default values if environment variables are not set. @@ -62,7 +73,9 @@ const { * mobile?: boolean * } * }} -*/ + */ +const scriptName = process.env.npm_lifecycle_event; +const SCENARIO_URLS = ScenarioUrls[scriptName]; /** * Exported WordPress environment configuration. @@ -102,5 +115,7 @@ export { WP_SSH_USERNAME, WP_SSH_ADDRESS, WP_SSH_KEY, - WP_SSH_ROOT_DIR + WP_SSH_ROOT_DIR, + SCENARIO_URLS, + IMAGIFY_INFOS }; \ No newline at end of file diff --git a/src/features/ll-lcp.feature b/src/features/ll-lcp.feature new file mode 100644 index 0000000..39e327b --- /dev/null +++ b/src/features/ll-lcp.feature @@ -0,0 +1,46 @@ +@lcp @delaylcp @setup +Feature: Lazyload with LCP + + Background: + Given I am logged in + And plugin is installed 'new_release' + And plugin 'wp-rocket' is activated + When I go to 'wp-admin/options-general.php?page=wprocket#dashboard' + And I save settings 'media' 'lazyloadCssBgImg' + And I save settings 'media' 'lazyload' + And I save settings 'media' 'lazyloadIframes' + And I save settings 'media' 'lazyloadYoutube' + + Scenario: Should Exclude LCP/ATF from Lazyload + When I log out + And I visit the urls for 'desktop' + When I am logged in + And I clear cache + And I log out + And I visit the urls and check for lazyload + Then lcp and atf images are not written to LL format + + Scenario: Should exclude next-gen lcp/atf from LL + Given I install plugin 'imagify' + And plugin 'imagify' is activated + When I am logged in + And Imagify is set up + When I log out + And I visit page 'lcp_with_imagify' and check for lcp + When I am logged in + And I clear cache + And I log out + And I visit the 'lcp_with_imagify' and check lcp-atf are not lazyloaded + Then lcp and atf images are not written to LL format + + Scenario: Should exclude Imagify next-gen lcp/atf from LL + When I am logged in + And display next-gen is enabled on imagify + When I log out + And I visit page 'lcp_with_imagify' and check for lcp + When I am logged in + And I clear cache + And I log out + And I visit the 'lcp_with_imagify' and check lcp-atf are not lazyloaded + Then lcp and atf images are not written to LL format + diff --git a/src/support/steps/general.ts b/src/support/steps/general.ts index 28baeb9..c45292a 100644 --- a/src/support/steps/general.ts +++ b/src/support/steps/general.ts @@ -273,8 +273,10 @@ When('I clear cache', async function (this:ICustomWorld) { await this.utils.gotoWpr(); this.sections.set('dashboard'); + const cacheButton = this.page.locator('p:has-text("This action will clear") + a').first(); await cacheButton.click(); + await expect(this.page.getByText('WP Rocket: Cache cleared.')).toBeVisible(); }); diff --git a/src/support/steps/imagify.ts b/src/support/steps/imagify.ts new file mode 100644 index 0000000..e167724 --- /dev/null +++ b/src/support/steps/imagify.ts @@ -0,0 +1,31 @@ +import { ICustomWorld } from "../../common/custom-world"; + +import { Given } from '@cucumber/cucumber'; +import { IMAGIFY_INFOS } from "../../../config/wp.config"; +import {expect} from "@playwright/test"; + +Given('Imagify is set up', async function (this: ICustomWorld) { + await this.utils.gotoImagify(); + + // Check if the API key input field exists on the page + const apiKeyInput = await this.page.$('input#api_key'); + + if (apiKeyInput) { + // Fill the API key input field with the API key from the config + await this.page.fill('input#api_key', IMAGIFY_INFOS.apiKey); + // Click the submit button to save the changes + await this.page.click('div.submit.imagify-clearfix input#submit'); + } +}); +Given('display next-gen is enabled on imagify', async function (this: ICustomWorld) { + // Go to Imagify setting page + await this.utils.gotoImagify(); + + // Check the 'Display images in Next-Gen format on the site' checkbox + await this.page.click('label[for="imagify_display_nextgen"]'); + + // Click the submit button to save the changes + await this.page.click('input#submit'); + + await expect(this.page.getByText('Settings saved.')).toBeVisible(); +}); \ No newline at end of file diff --git a/src/support/steps/lcp-beacon-script.ts b/src/support/steps/lcp-beacon-script.ts index cd7508b..06972e3 100644 --- a/src/support/steps/lcp-beacon-script.ts +++ b/src/support/steps/lcp-beacon-script.ts @@ -8,24 +8,82 @@ * @requires {@link @playwright/test} * @requires {@link @cucumber/cucumber} */ -import { ICustomWorld } from "../../common/custom-world"; -import { expect } from "@playwright/test"; -import { When, Then } from "@cucumber/cucumber"; -import { LcpData, Row } from "../../../utils/types"; - -import { dbQuery, getWPTablePrefix } from "../../../utils/commands"; -import { extractFromStdout } from "../../../utils/helpers"; -import { WP_BASE_URL } from '../../../config/wp.config'; +import {ICustomWorld} from "../../common/custom-world"; +import {expect} from "@playwright/test"; +import {Then, When} from "@cucumber/cucumber"; +import {LcpData, LLImagesData, Row, SinglePageLCPImages} from "../../../utils/types"; + +import {dbQuery, getWPTablePrefix} from "../../../utils/commands"; +import {checkLcpOrViewport, extractFromStdout} from "../../../utils/helpers"; +import {WP_BASE_URL} from '../../../config/wp.config'; import fs from 'fs/promises'; let data: string, truthy: boolean = true, failMsg: string, jsonData: Record, - isDbResultAvailable: boolean = true; + isDbResultAvailable: boolean = true, + lcpLLImages: LLImagesData = {}, + singlePageLcp : SinglePageLCPImages = {url: '', lcp: '', viewport: ''}; const actual: LcpData = {}; +/** + * Executes step to visit page based on the templates and get check for lazyload. + */ +When('I visit the urls and check for lazyload', async function (this: ICustomWorld) { + const resultFile: string = './src/support/results/expectedResultsDesktop.json'; + + await this.page.setViewportSize({ + width: 1600, + height: 700 + }); + + data = await fs.readFile(resultFile, 'utf8'); + jsonData = JSON.parse(data); + + // Visit page. + for (const key in jsonData) { + if ( jsonData[key].enabled === true ) { + // Visit the page url. + await this.utils.visitPage(key); + + lcpLLImages = await this.page.evaluate((url) => { + const images = document.querySelectorAll('img'), + result = {}, + allElements = document.querySelectorAll('*'); + + Array.from(images).forEach((img) => { + result[`${url}_img`] = { + src: img.getAttribute('src'), + type: 'image', + url: url, + lazyloaded: img.classList.contains('lazyloaded') + } + }); + + Array.from(allElements).forEach((element) => { + const computedStyle = window.getComputedStyle(element); + const backgroundImage = computedStyle.backgroundImage; + + if (backgroundImage && backgroundImage !== 'none') { + const bgUrl = backgroundImage.replace(/^url\(['"]?/, '').replace(/['"]?\)$/, ''); + + result[`${url}_bg`] = { + type: 'background', + src: bgUrl, + url: url, + lazyloaded: element.classList.contains('data-rocket-lazy-bg'), + }; + } + }) + + + return result; + }, key); + } + } +}); /** * Executes step to visit page based on the form factor(desktop/mobile) and get the LCP/ATF data from DB. */ @@ -71,7 +129,7 @@ When('I visit the urls for {string}', async function (this: ICustomWorld, formFa await this.page.waitForFunction(() => { const beacon = document.querySelector('[data-name="wpr-wpr-beacon"]'); return beacon && beacon.getAttribute('beacon-completed') === 'true'; - }, { timeout: 900000 }); + }, { timeout: 100000 }); if (formFactor !== 'desktop') { isMobile = 1; @@ -152,18 +210,21 @@ Then('lcp and atf should be as expected for {string}', async function (this: ICu expect(truthy).toBeTruthy(); }); +let lcpImages: Array<{ src: string; fetchpriority: string | boolean; lazyloaded: string | boolean }> = []; + Then('lcp image should have fetchpriority', async function (this: ICustomWorld) { truthy= false; - const imageWithFetchPriority = await this.page.evaluate(() => { + lcpImages = await this.page.evaluate(() => { const images = document.querySelectorAll('img'); return Array.from(images).map(img => ({ src: img.getAttribute('src'), - fetchpriority: img.getAttribute('fetchpriority') || false + fetchpriority: img.getAttribute('fetchpriority') || false, + lazyloaded: img.classList.contains('lazyloaded') })); }); - for (const image of imageWithFetchPriority) { + for (const image of lcpImages) { if(image.src === '/wp-content/rocket-test-data/images/600px-Mapang-test.gif' && image.fetchpriority !== false) { truthy = true } @@ -171,3 +232,115 @@ Then('lcp image should have fetchpriority', async function (this: ICustomWorld) expect(truthy).toBeTruthy(); }); + +/** + * Executes the step to assert that LCP & ATF aren't lazyloaded. + * + * @returns {Promise} + */ +Then('lcp and atf images are not written to LL format', async function (this: ICustomWorld) { + // Reset truthy to true here. + truthy = true; + + // Iterate over the data + for (const key in jsonData) { + if (Object.hasOwnProperty.call(jsonData, key) && jsonData[key].enabled === true) { + const expected = jsonData[key]; + + const lcpResult = await checkLcpOrViewport(lcpLLImages, key, 'LCP', expected.lcp); + if (lcpResult && !lcpResult.isValid) { + truthy = false; + failMsg += lcpResult.errorMessages.join(''); + } + + const viewportResult = await checkLcpOrViewport(lcpLLImages, key, 'Viewport', expected.viewport); + if (viewportResult && !viewportResult.isValid) { + truthy = false; + failMsg += viewportResult.errorMessages.join(''); + } + } + } + + // Fail test when there is expectation mismatch. + expect(truthy).toBeTruthy(); +}); + +When('I visit the {string} and check lcp-atf are not lazyloaded', async function (this: ICustomWorld, url: string) { + // Reset truthy to true here. + truthy = true; + + await this.page.setViewportSize({ + width: 1600, + height: 700 + }); + + await this.utils.visitPage(url); + + const allImages = await this.page.evaluate((url) => { + const images = document.querySelectorAll('img'), + result = []; + + Array.from(images).forEach((img) => { + result.push({ + src: img.getAttribute('src'), + url: url, + lazyloaded: img.classList.contains('lazyloaded') + }) + }); + + return result; + }, url); + + allImages.forEach((image) => { + if(singlePageLcp.lcp.includes(image.src) && image.lazyloaded ) { + truthy = false; + } + + if(singlePageLcp.viewport.includes(image.src) && image.lazyloaded ) { + truthy = false; + } + }); + + // Fail test when there is expectation mismatch. + expect(truthy).toBeTruthy(); +}); + +/** + * Executes the step to visit page in a specific browser dimension. + */ +When('I visit page {string} and check for lcp', async function (this:ICustomWorld, page) { + + const tablePrefix: string = await getWPTablePrefix(); + + await this.page.setViewportSize({ + width: 1600, + height: 700, + }); + + await this.utils.visitPage(page); + + // Wait the beacon to add an attribute `beacon-complete` to true before fetching from DB. + await this.page.waitForFunction(() => { + const beacon = document.querySelector('[data-name="wpr-wpr-beacon"]'); + return beacon && beacon.getAttribute('beacon-completed') === 'true'; + }, { timeout: 100000 }); + + // Get the LCP/ATF from the DB + const sql = `SELECT lcp, viewport + FROM ${tablePrefix}wpr_above_the_fold + WHERE url LIKE "%${page}%" + AND is_mobile = 0`; + const result = await dbQuery(sql); + const resultFromStdout = await extractFromStdout(result); + + // If no DB result, set assertion var to false, fail msg and skip the loop. + if (!resultFromStdout || resultFromStdout.length === 0) { + isDbResultAvailable = false; + } + + singlePageLcp = { + url: page, + lcp: resultFromStdout[0].lcp, + viewport: resultFromStdout[0].viewport + } +}); \ No newline at end of file diff --git a/utils/commands.ts b/utils/commands.ts index 7cfb2ba..64b8a2a 100644 --- a/utils/commands.ts +++ b/utils/commands.ts @@ -239,7 +239,7 @@ export async function activatePlugin(name: string): Promise { /** * Check if plugin is installed * @function - * @name activatePlugin + * @name isPluginInstalled * @async * @param {string} name - The name of the plugin to be checked if installed. * @returns {Promise} - A Promise that resolves when the check is completed. @@ -248,6 +248,18 @@ export async function isPluginInstalled(name: string): Promise { return await wp(`plugin is-installed ${name}`, false); } +/** + * Delete a plugin if exist. + * Note: this is not ideal for wpr or imagify plugins as it doesn't delete DB data which relies on uninstall hook. + * @function + * @name deletePlugin + * @async + * @param {string} name - The name of the plugin to be deleted if installed. + * @returns {Promise} - A Promise that resolves when the check is completed. + */ +export async function deletePlugin(name: string): Promise { + return await wp(`plugin delete ${name}`, false); +} /** * Install a WordPress plugin from a remote zip file using the WP-CLI command. diff --git a/utils/helpers.ts b/utils/helpers.ts index 725a4b8..6219544 100644 --- a/utils/helpers.ts +++ b/utils/helpers.ts @@ -18,7 +18,7 @@ import backstop from 'backstopjs'; import { Pickle } from '@cucumber/messages'; // Interfaces -import { ExportedSettings, VRurlConfig, Viewport, Row } from '../utils/types'; +import {ExportedSettings, VRurlConfig, Viewport, Row, LLImagesData} from '../utils/types'; import { uiReflectedSettings } from './exclusions'; import { WP_BASE_URL } from '../config/wp.config'; import { dbQuery } from './commands'; @@ -70,6 +70,48 @@ const getDir = async (file: string): Promise => { return dir; } +/** + * Check LCP/ATF images does not have lazyload attribute, either as image or background-image + */ +export const checkLcpOrViewport = async (images: LLImagesData, type: string, key: string, values: string[]): Promise<{ + isValid: boolean; + errorMessages: string[] +}> => { + let result = { + lcpImage: '', + lcpLLStatus: true, + }, lcpUrl: string | boolean; + + let isValid = true; + const errorMessages : string[] = []; + + for (const value of values) { + if (images[`${key}_bg`] && images[`${key}_bg`].src.includes(value) && images[`${key}_bg`].lazyloaded) { + result = { + lcpImage: images[`${key}_bg`].src, + lcpLLStatus: false, + }; + lcpUrl = images[`${key}_bg`].url + } + + if (images[`${key}_image`] && images[`${key}_image`].src.includes(value) && images[`${key}_image`].lazyloaded) { + result = { + lcpImage: images[`${key}_image`].src, + lcpLLStatus: false, + }; + lcpUrl = images[`${key}_bg`].url + } + + if (!result.lcpLLStatus) { + isValid = false; + errorMessages.push( + `Expected ${type} for - ${value} for ${lcpUrl} is lazyloaded - ${result.lcpImage}\n\n\n` + ); + } + } + + return { isValid, errorMessages }; +} /** * Read the content of a file. * diff --git a/utils/page-utils.ts b/utils/page-utils.ts index 58d19dd..c23e692 100644 --- a/utils/page-utils.ts +++ b/utils/page-utils.ts @@ -118,6 +118,15 @@ export class PageUtils { await this.page.goto(WP_BASE_URL + '/wp-admin/options-general.php?page=wprocket#dashboard'); } + /** + * Navigates to Imagify settings page. + * + * @return {Promise} + */ + public gotoImagify = async (): Promise => { + await this.page.goto(WP_BASE_URL + '/wp-admin/options-general.php?page=imagify'); + } + /** * Navigates to new post on Wordpress. * diff --git a/utils/types.ts b/utils/types.ts index d03be64..a577347 100644 --- a/utils/types.ts +++ b/utils/types.ts @@ -111,4 +111,18 @@ export interface LcpData { export interface Row { [key: string]: string +} + +export interface SinglePageLCPImages { + url: string, + lcp: string, + viewport: string +} +export interface LLImagesData { + [key: string] : { + src: string; + type: string; + url: string | boolean; + lazyloaded: string | boolean + } } \ No newline at end of file