diff --git a/package.json b/package.json index 01666ae..b31e255 100755 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "test:llimages": "$npm_package_config_testCommand --tags @llimages", "test:lcp": "$npm_package_config_testCommand --tags @lcp", "test:test": "$npm_package_config_testCommand --tags @test", + "test:performancehints": "$npm_package_config_testCommand --tags @performancehints", "healthcheck": "ts-node healthcheck.ts", "wp-env": "wp-env" }, diff --git a/src/features/performance-hints.feature b/src/features/performance-hints.feature new file mode 100644 index 0000000..392e779 --- /dev/null +++ b/src/features/performance-hints.feature @@ -0,0 +1,53 @@ +@setup @performancehints +Feature: Clear lcp/performance hints data tests + + Background: + Given I am logged in + And plugin is installed 'new_release' + And plugin is activated + + Scenario: C16387 - Should clear performance hints data when click clear PH in admin bar + Given performance hints data added to DB + When clear performance hints is clicked in admin bar + Then data is removed from the performance hints tables + + Scenario: C16389 - Should clear performance hints when change permalinks + Given performance hints data added to DB + When permalink structure is changed to '/%postname%' + Then data is removed from the performance hints tables + + Scenario: C16390 - Should clear performance hints when switch theme + Given performance hints data added to DB + And switching the theme + Then data is removed from the performance hints tables + Then theme 'Twenty Twenty' is activated + + Scenario: Should clear performance hints of the current URL + Given I log out + And I visit beacon driven page 'atf-lrc-1' with browser dimension 1600 x 700 + And I visit beacon driven page 'atf-lrc-2' with browser dimension 1600 x 700 + And data for 'atf-lrc-1' present in the performance hints tables + And data for 'atf-lrc-2' present in the performance hints tables + And I am logged in + And I go to 'atf-lrc-1' + When clear performance hints for this URL is clicked in admin bar + Then data for 'atf-lrc-1' is removed from the performance hints tables + Then data for 'atf-lrc-2' present in the performance hints tables + + Scenario: C16388 - Should clear performance hints of the URL when edited + Given I log out + And I visit beacon driven page 'atf-lrc-1' with browser dimension 1600 x 700 + And data for 'atf-lrc-1' present in the performance hints tables + And I am logged in + And I go to 'atf-lrc-1' + When I edit the content of post + Then data for 'atf-lrc-1' is removed from the performance hints tables + + Scenario: C16388 - Should clear performance hints of the URL when deleted + Given I log out + And I visit beacon driven page 'atf-lrc-1' with browser dimension 1600 x 700 + And data for 'atf-lrc-1' present in the performance hints tables + And I am logged in + When 'atf-lrc-1' page is deleted + Then data for 'atf-lrc-1' is removed from the performance hints tables + Then untrash and republish 'atf-lrc-1' page \ No newline at end of file diff --git a/src/support/steps/general.ts b/src/support/steps/general.ts index bde5458..2fd73bd 100644 --- a/src/support/steps/general.ts +++ b/src/support/steps/general.ts @@ -261,6 +261,24 @@ When('I visit page {string} with browser dimension {int} x {int}', async functio await this.utils.visitPage(page); }); +/** + * Executes the step to visit beacon driven page in a specific browser dimension. + */ +When('I visit beacon driven page {string} with browser dimension {int} x {int}', async function (this:ICustomWorld, page, width, height) { + await this.page.setViewportSize({ + width: width, + height: height, + }); + + 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'; + }); +}); + /** * Executes the step to scroll to the bottom of the page. */ @@ -268,6 +286,13 @@ When('I scroll to bottom of page', async function (this:ICustomWorld) { await this.utils.scrollDownBottomOfAPage(); }); +/** + * Executes the step to change permalink structure. + */ +When('permalink structure is changed to {string}', async function (this: ICustomWorld, structure: string) { + await this.utils.permalinkChanged(structure); +}); + /** * Executes the step to assert the presence of specific text. */ diff --git a/src/support/steps/performance-hints.ts b/src/support/steps/performance-hints.ts new file mode 100644 index 0000000..2d1510c --- /dev/null +++ b/src/support/steps/performance-hints.ts @@ -0,0 +1,144 @@ +/** + * @fileoverview + * This module contains Cucumber step definitions using Playwright for asserting performance hints data clearing from DB, + * both ATF and LRC tables + * + * @requires {@link ../../common/custom-world} + * @requires {@link @playwright/test} + * @requires {@link @cucumber/cucumber} + */ + +import { ICustomWorld } from "../../common/custom-world"; +import { WP_BASE_URL } from '../../../config/wp.config'; +import { When, Then, Given } from '@cucumber/cucumber'; +import { dbQuery, getWPTablePrefix, getPostDataFromTitle, updatePostStatus } from "../../../utils/commands"; +import { extractFromStdout, seedData, checkData } from "../../../utils/helpers"; + +/* + * Executes step to add hardcoded data to DB: ATF & LRC tables + */ +Given('performance hints data added to DB', async function (this: ICustomWorld) { + const tablePrefix = await getWPTablePrefix(); + const tableNames = [ + `${tablePrefix}wpr_above_the_fold`, + `${tablePrefix}wpr_lazy_render_content` + ]; + + // Define the data to be inserted + const data = [ + // eslint-disable-next-line @typescript-eslint/naming-convention + { url: `${WP_BASE_URL}/a`, is_mobile: 0, status: 'completed' }, + // eslint-disable-next-line @typescript-eslint/naming-convention + { url: `${WP_BASE_URL}/b`, is_mobile: 0, status: 'completed' }, + // eslint-disable-next-line @typescript-eslint/naming-convention + { url: `${WP_BASE_URL}/c`, is_mobile: 0, status: 'completed' } + ]; + + await Promise.all(tableNames.map(async (tableName) => { + await dbQuery(`TRUNCATE TABLE ${tableName}`); + })); + + await seedData(tableNames, data); +}); + +When('clear performance hints is clicked in admin bar', async function (this: ICustomWorld) { + await this.page.locator('#wp-admin-bar-wp-rocket').hover(); + await this.page.waitForSelector('#wp-admin-bar-clear-performance-hints', { state: 'visible' }); + await this.page.locator('#wp-admin-bar-clear-performance-hints').click(); + await this.page.waitForSelector('text=WP Rocket: Critical images and Lazy Render data was cleared!', { state: 'visible' }); +}); + +When('clear performance hints for this URL is clicked in admin bar', async function (this: ICustomWorld) { + await this.page.locator('#wp-admin-bar-wp-rocket').hover(); + await this.page.waitForSelector('#wp-admin-bar-clear-performance-hints-data-url', { state: 'visible' }); + await this.page.locator('#wp-admin-bar-clear-performance-hints-data-url').click(); +}); + +/* + * Executes the step to check all data has been cleared from ATF & LRC tables + * (home URL ignored as its the quickest to re-appear on prewarmup) + */ +Then('data is removed from the performance hints tables', async function (this: ICustomWorld) { + const tablePrefix = await getWPTablePrefix(); + const tables = [`${tablePrefix}wpr_above_the_fold`, `${tablePrefix}wpr_lazy_render_content`]; + const data = [ + { url: `${WP_BASE_URL}/a` }, + { url: `${WP_BASE_URL}/b` }, + { url: `${WP_BASE_URL}/c` } + ]; + + await checkData(tables, data); +}); + +/* + * Executes the step to check data has been cleared from ATF & LRC tables for specific URL + */ +Then('data for {string} is removed from the performance hints tables', async function (this: ICustomWorld, permalink: string) { + const tablePrefix = await getWPTablePrefix(); + const tables = [`${tablePrefix}wpr_above_the_fold`, `${tablePrefix}wpr_lazy_render_content`]; + const data = [ + { url: `${WP_BASE_URL}/${permalink}` } + ]; + + await checkData(tables, data); +}); + +/* + * Executes the step to check data still exists in the ATF & LRC tables for specific URL + */ +Then('data for {string} present in the performance hints tables', async function (this: ICustomWorld, permalink: string) { + const tablePrefix = await getWPTablePrefix(); + const tables = [`${tablePrefix}wpr_above_the_fold`, `${tablePrefix}wpr_lazy_render_content`]; + const data = [ + { url: `${WP_BASE_URL}/${permalink}` } + ]; + + await checkData(tables, data, true); +}); + +When('switching the theme', async function (this: ICustomWorld) { + await this.utils.gotoThemes(); + await this.page.locator('#wpbody-content > div.wrap > div.theme-browser.rendered > div > div:nth-child(2) > div.theme-id-container').hover(); + await this.page.waitForSelector('#wpbody-content > div.wrap > div.theme-browser.rendered > div > div:nth-child(2) > div.theme-id-container > div > a.button.activate', { state: 'visible' }); + await this.page.locator('#wpbody-content > div.wrap > div.theme-browser.rendered > div > div:nth-child(2) > div.theme-id-container > div > a.button.activate').click(); +}); + +When ('I edit the content of post', async function (this: ICustomWorld) { + await this.page.waitForSelector('#wp-admin-bar-edit', { state: 'visible' }); + await this.page.locator('#wp-admin-bar-edit').click(); + + // Check for 'Update' button. + const updateButton = this.page.getByRole('button', { name: 'Update', exact: true }); + + try { + // Wait for the 'Update' button. + await updateButton.waitFor({ state: 'visible' }); + await updateButton.click(); + } catch (error) { + // If 'Update' is not found, check for 'Save' button for WP version >= 6.6.2. + const saveButton = this.page.getByRole('button', { name: 'Save', exact: true }); + + // Wait for the 'Save' button. + await saveButton.waitFor({ state: 'visible' }); + await saveButton.click(); + } + + await this.page.waitForSelector('[aria-label="Dismiss this notice"]', { state: 'visible' }); +}); + +When ('{string} page is deleted', async function (this: ICustomWorld, permalink: string) { + await this.utils.gotoPages(); + await this.page.locator('#post-search-input').fill(permalink); + await this.page.locator('#search-submit').click(); + await this.page.locator('td.title.column-title.has-row-actions.column-primary.page-title > strong > a').hover(); + await this.page.waitForSelector('div.row-actions > span.trash > a', { state: 'visible' }); + await this.page.locator('div.row-actions > span.trash > a').click(); + await this.page.waitForSelector('#message', { state: 'visible' }); +}); + + +Then ('untrash and republish {string} page', async function (this: ICustomWorld, permalink: string) { + const postDataStdout = await getPostDataFromTitle(permalink, 'trash', 'ID,post_title'); + const postData = await extractFromStdout(postDataStdout); + await updatePostStatus(parseInt(postData[0].ID, 10), 'publish'); +}); \ No newline at end of file diff --git a/utils/commands.ts b/utils/commands.ts index 175c9b5..bc977ee 100644 --- a/utils/commands.ts +++ b/utils/commands.ts @@ -272,11 +272,27 @@ export async function installRemotePlugin(url: string): Promise { * @returns {Promise} - A Promise that resolves when the uninstallation is completed. */ export async function uninstallPlugin(plugin: string): Promise { - if(await isPluginInstalled(plugin)) { - await wp(`plugin uninstall --deactivate ${plugin}`); + const plugins = plugin.split(' '); + for (const p of plugins) { + if (await isPluginInstalled(p)) { + await wp(`plugin uninstall --deactivate ${p}`); + } } } +/** + * Update Permalink. + * + * @function + * @name updatePermalinkStructure + * @async + * @param {string} structure - The permalink structure. + * @returns {Promise} - A Promise that resolves when the permalink structure is updated. + */ +export async function updatePermalinkStructure(structure: string): Promise { + await wp(`option update permalink_structure ${structure}`); +} + /** * Executes a SQL query on the WordPress database using WP-CLI. * @@ -439,4 +455,35 @@ export async function testSshConnection(): Promise { } } +/** + * Performs a post search action by title using wp cli. + * + * @param {string} title Post Title. + * @param {string} status Post Status. + * @param {string} fields Post fields to return. + * @return {Promise} A Promise that resolves when the post search is executed. + */ +export async function getPostDataFromTitle(title: string, status: string, fields: string): Promise { + const command = wrapSSHPrefix(`wp post list --post_status=${status} --post_type=page --fields=${fields} --title='${title}' +`); + const result = exec(command, { silent: true }); + + if (result.code === 1) { + return ''; + } + + return result.stdout; +} + +/** + * Updates post status using wp cli. + * + * @param {string} id Post ID. + * @param {string} status Post Status. + * @return {Promise} A Promise that resolves when the post search is executed. + */ +export async function updatePostStatus(id: number, status: string): Promise { + await wp(`post update ${id} --post_status=${status}`); +} + export default wp; \ No newline at end of file diff --git a/utils/helpers.ts b/utils/helpers.ts index ca67e68..eb959d2 100644 --- a/utils/helpers.ts +++ b/utils/helpers.ts @@ -13,12 +13,14 @@ import os from 'os'; import fs from 'fs/promises'; import type { Page } from '@playwright/test'; +import { expect } from '@playwright/test'; import backstop from 'backstopjs'; // Interfaces import { ExportedSettings, VRurlConfig, Viewport, Row } from '../utils/types'; import { uiReflectedSettings } from './exclusions'; import { WP_BASE_URL } from '../config/wp.config'; +import { dbQuery } from './commands'; const backstopConfig = './backstop.json'; /** @@ -396,3 +398,83 @@ export const extractFromStdout = async(data: string): Promise => { return result; } + +/** + * Inserts new data to DB Tables. + * + * @param {Array} tables Array of table names parsed. + * @param { Array<{ [key: string]: string | number }>} data Data to populate tables. + * + * @return {} Promise that resolves after data has been inserted and confirmed. + */ +export const seedData = async(tables: Array, data: Array<{ [key: string]: string | number }>): Promise => { + // Dynamically build the values string from the data constant + const values = data.map(row => + `(${Object.values(row).map(value => + typeof value === 'string' ? `"${value}"` : value + ).join(', ')})` + ).join(', '); + + const insertSql = `INSERT INTO %s (${Object.keys(data[0]).join(', ')}) VALUES ${values}`; + const selectSql = `SELECT ${Object.keys(data[0]).join(', ')} FROM %s`; + + for (const table of tables) { + // Insert data to tables. + const insertQuery = insertSql.replace('%s', table); + await dbQuery(insertQuery); + + // Check that data actually exists in DB. + const selectQuery = selectSql.replace('%s', table); + const resultString = await dbQuery(selectQuery); + + const result = await extractFromStdout(resultString); + + // Filter out the rows that match the hardcoded data + const filteredResult = data.filter(hardcodedRow => + result.some(dbRow => + Object.keys(hardcodedRow).every(key => { + const dbValue = dbRow[key as keyof Row]; + const hardcodedValue = hardcodedRow[key]; + return typeof hardcodedValue === 'number' + ? Number(dbValue) === hardcodedValue + : dbValue === hardcodedValue; + }) + ) + ); + + // Check if the filtered result contains all the hardcoded data + expect(filteredResult.length).toBe(data.length); // Ensure all hardcoded data is found + } +} + +/** + * Checks that data is removed from table. + * + * @param {Array} tables Array of table names parsed. + * @param {Array<{ [key: string]: string | number }>} data Data to be checked in tables. + * @param {boolean} exists Should check if data exists or is removed. + * + * @return {} Promise that resolves after data has been confirmed as removed. + */ +export const checkData = async(tables: Array, data: Array<{ [key: string]: string | number }>, exists: boolean = false): Promise => { + // Verify both tables + for (const table of tables) { + const selectSql = `SELECT * FROM ${table}`; + const result = await dbQuery(selectSql); + const resultFromStdout = await extractFromStdout(result); + + // Filter out any URL that does not exists in the dataset and adds it to the constant. + const filteredData = data.filter(urlObj => { + return !resultFromStdout.some(item => { + return Object.entries(urlObj).every(([key, value]) => item[key] === value); + }); + }); + + if ( exists ) { + expect(0).toBe(filteredData.length); + continue; + } + + expect(data.length).toBe(filteredData.length); + } +} diff --git a/utils/page-utils.ts b/utils/page-utils.ts index 822169a..8efd13f 100644 --- a/utils/page-utils.ts +++ b/utils/page-utils.ts @@ -16,7 +16,7 @@ import { ICustomWorld } from '../src/common/custom-world'; import fs from "fs/promises"; import {WP_BASE_URL, WP_PASSWORD, WP_USERNAME} from '../config/wp.config'; -import { uninstallPlugin } from "./commands"; +import { uninstallPlugin, updatePermalinkStructure } from "./commands"; /** * Utility class for interacting with a Playwright Page instance in WordPress testing. @@ -188,6 +188,25 @@ export class PageUtils { await this.page.locator('text=Preview in new tab').click(); } + /** + * Changes permalink custom structure to have ending slash. + * + * @return {Promise} + */ + public permalinkChanged = async (structure: string): Promise => { + await this.page.goto(WP_BASE_URL + '/wp-admin/options-permalink.php'); + const permalinkLocator = '#permalink_structure'; + + if (await this.page.locator(permalinkLocator).inputValue() === structure) { + return; + } + + await this.page.locator(permalinkLocator).fill(structure); + // Save changes. + await this.page.locator('#submit').click(); + await this.page.waitForSelector('text=Settings saved.', { state: 'visible' }); + } + /** * Peforms a WPR menu dropdown action. * @@ -238,6 +257,24 @@ export class PageUtils { await this.page.goto(WP_BASE_URL + '/wp-admin/themes.php'); } + /** + * Navigates to Wordpress Pages page. + * + * @return {Promise} + */ + public gotoPages = async (): Promise => { + await this.page.goto(WP_BASE_URL + '/wp-admin/edit.php?post_type=page'); + } + + /** + * Navigates to Wordpress Pages - Trash tab. + * + * @return {Promise} + */ + public gotoTrashedPages = async (): Promise => { + await this.page.goto(WP_BASE_URL + '/wp-admin/edit.php?post_status=trash&post_type=page'); + } + /** * Navigates to Wordpress site health page. * @@ -277,11 +314,18 @@ export class PageUtils { * @return {Promise} */ public wpAdminLogout = async (): Promise => { - await this.page.locator('#wp-admin-bar-my-account').isVisible(); - await this.page.locator('#wp-admin-bar-my-account').hover(); - await this.page.waitForSelector('#wp-admin-bar-logout'); - await this.page.locator('#wp-admin-bar-logout a').click(); - await expect(this.page.getByText('You are now logged out.')).toBeVisible(); + // Navigate to the logout URL directly + await this.page.goto(WP_BASE_URL + '/wp-login.php?action=logout'); + + const logoutLink = this.page.getByRole('link', { name: 'log out' }); + if (!await logoutLink.isVisible()) { + return; + } + + await logoutLink.click(); + await expect(this.page.getByText('You are now logged out.')).toBeVisible({ timeout: 10000 }); + await this.page.goto(WP_BASE_URL + '/wp-login.php'); + await expect(this.page.locator('#loginform')).toBeVisible(); } /** @@ -531,8 +575,23 @@ export class PageUtils { */ public cleanUp = async (): Promise => { // Remove helper plugin. - await uninstallPlugin('force-wp-mobile'); + await uninstallPlugin('wp-rocket force-wp-mobile'); + + // Reset permalink structure. + await updatePermalinkStructure('/%postname%/'); + + // Remove WP Rocket from UI if on local run is explicitly parsed. + if ( process.env.npm_config_env !== undefined && process.env.npm_config_env === 'local' ) { + await this.removeWprViaUi(); + } + } + /** + * Removes WP Rocket via the WP Admin UI. + * + * @return {Promise} Promise that resolves after the uninstallation process is complete. + */ + public removeWprViaUi = async (): Promise => { // Start the process to remove wp-rocket. await this.visitPage('wp-admin'); await this.auth(); @@ -572,7 +631,7 @@ export class PageUtils { // Assert that WPR is deleted successfully await this.page.waitForSelector('#wp-rocket-deleted'); - await expect(this.page.locator('#wp-rocket-deleted')).toBeVisible(); + await expect(this.page.locator('#wp-rocket-deleted')).toBeVisible(); } /**