diff --git a/package-lock.json b/package-lock.json index 0a7bd40..5a5f90a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,11 +9,13 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "@types/axios": "^0.14.0", "@types/ssh2": "^1.11.13", + "axios": "^1.6.8", "backstopjs": "^6.2.2", "json-diff": "^1.0.6", "node-ssh": "^13.1.0", - "node-zip": "^1.1.1", + "node-zip": "^1.0.1", "playwright": "^1.34.3", "shelljs": "^0.8.5", "ssh2": "^1.14.0", @@ -785,6 +787,15 @@ "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", "dev": true }, + "node_modules/@types/axios": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@types/axios/-/axios-0.14.0.tgz", + "integrity": "sha512-KqQnQbdYE54D7oa/UmYVMZKq7CO4l8DEENzOKc4aBRwxCXSlJXGz83flFx5L7AWrOQnmuN3kVsRdt+GZPPjiVQ==", + "deprecated": "This is a stub types definition for axios (https://github.com/mzabriskie/axios). axios provides its own type definitions, so you don't need @types/axios installed!", + "dependencies": { + "axios": "*" + } + }, "node_modules/@types/backstopjs": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/@types/backstopjs/-/backstopjs-6.1.1.tgz", @@ -1353,6 +1364,21 @@ "lodash": "^4.17.14" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "node_modules/axios": { + "version": "1.6.8", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.8.tgz", + "integrity": "sha512-v/ZHtJDU39mDpyBoFVkETcd/uNdxrWRrg3bKpOKzXFA6Bvqopts6ALSMU3y6ijYxbw2B+wPrIv46egTzJXCLGQ==", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/backstopjs": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/backstopjs/-/backstopjs-6.2.2.tgz", @@ -1775,6 +1801,17 @@ "node": ">=0.1.90" } }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/commander": { "version": "10.0.1", "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", @@ -2013,6 +2050,14 @@ "node": ">=8" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -2689,6 +2734,38 @@ "integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==", "dev": true }, + "node_modules/follow-redirects": { + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", + "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -5698,4 +5775,4 @@ } } } -} \ No newline at end of file +} diff --git a/package.json b/package.json index 90b29c7..ece102e 100755 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "test:delayjs:flatsome": "THEME=flatsome $npm_package_config_testCommand --tags @delayjs", "test:delayjs:divi": "THEME=Divi $npm_package_config_testCommand --tags @delayjs", "test:delayjs:astra": "THEME=astra $npm_package_config_testCommand --tags @delayjs", + "test:lcp": "$npm_package_config_testCommand --tags @lcp", "test:test": "$npm_package_config_testCommand --tags @test", "wp-env": "wp-env" }, @@ -51,7 +52,9 @@ }, "homepage": "https://github.com/wp-media/wp-rocket-e2e#readme", "dependencies": { + "@types/axios": "^0.14.0", "@types/ssh2": "^1.11.13", + "axios": "^1.6.8", "backstopjs": "^6.2.2", "json-diff": "^1.0.6", "node-ssh": "^13.1.0", diff --git a/src/features/delay-js.feature b/src/features/delay-js.feature index 95ba3f4..3c44cf1 100644 --- a/src/features/delay-js.feature +++ b/src/features/delay-js.feature @@ -24,7 +24,7 @@ Feature: No Regression with delayjs script udpate And I save settings 'cache' 'mobileDeviceSeparateCache' When theme is activated And I log out - And visit page '' in mobile view + And I visit '' in mobile view And expand mobile menu And I click on link Then page navigated to the new page 'about-us' diff --git a/src/features/lcp-beacon-script.feature b/src/features/lcp-beacon-script.feature new file mode 100644 index 0000000..819e74c --- /dev/null +++ b/src/features/lcp-beacon-script.feature @@ -0,0 +1,35 @@ +@lcp @setup +Feature: Beacon script captures the right images. + + Background: + Given I am logged in + And plugin is installed 'new_release' + And plugin is activated + + Scenario: Beacon captures expected images in desktop + When I log out + And I visit the following urls in 'desktop' + | path | atfs | + | lcp_bg_inline_template | /wp-content/rocket-test-data/images/lcp/testjpeg.jpeg | + | lcp_bg_samestyle_template | wp-content/rocket-test-data/images/lcp/testjpg.jpg | + | lcp_img_loadedbydynamicjs_template | http://www.google.com/intl/en_com/images/logo_plain.png | + | lcp_img_loadedbyjs_template | /test.png, https://new.rocketlabsqa.ovh/wp-content/rocket-test-data/images/istockphoto-1184692500-612x612.webp | + | lcp_with_space_after_title | /wp-content/rocket-test-data/images/test_inline2.jpeg | + | lcp_test_template | /wp-content/rocket-test-data/images/lipsum_logo.jpg | + | lcp_bg_responsive_webkit_template | /wp-content/rocket-test-data/image/test3.webp, /wp-content/rocket-test-data/images/lcp/testwebp.webp | + | lcp_regular_image_template | /test.png, /wp-content/rocket-test-data/images/test_inline2.jpeg, /wp-content/rocket-test-data/images/Przechwytywanie.PNG, /wp-content/rocket-test-data/images/file_example_JPG_100kB.jpg | + Then lcp and atf should be as expected in 'desktop' + + Scenario: Beacon captures expected images in mobile + When I log out + And I visit the following urls in 'mobile' + | path | atfs | + | lcp_bg_inline_template | /wp-content/rocket-test-data/images/test_inline2.jpeg | + | lcp_bg_samestyle_template | https://new.rocketlabsqa.ovh/wp-content/rocket-test-data/images/lcp/testavif.avif | + | lcp_img_loadedbydynamicjs_template | http://www.google.com/intl/en_com/images/logo_plain.png | + | lcp_img_loadedbyjs_template | /test.png, https://new.rocketlabsqa.ovh/wp-content/rocket-test-data/images/istockphoto-1184692500-612x612.webp | + | lcp_with_space_after_title | https://new.rocketlabsqa.ovh/wp-content/rocket-test-data/images/test_inline2.jpeg | + | lcp_test_template | /wp-content/rocket-test-data/images/test_internal2.jpg, https://new.rocketlabsqa.ovh/wp-content/rocket-test-data/images/lipsum_logo.jpg | + | lcp_bg_responsive_webkit_template | https://rocketlabsqa.ovh/wp-content/rocket-test-data/images/fixtheissue.jpg | + | lcp_regular_image_template | /test.png, /wp-content/rocket-test-data/images/test_inline2.jpeg, /wp-content/rocket-test-data/images/lcp/testPng.png, /wp-content/rocket-test-data/images/Przechwytywanie.PNG, /wp-content/rocket-test-data/images/file_example_JPG_100kB.jpg, /wp-content/rocket-test-data/images/img_nature.jpg, /wp-content/rocket-test-data/images/mountain.webp | + Then lcp and atf should be as expected in 'mobile' diff --git a/src/support/steps/general.ts b/src/support/steps/general.ts index 9e2955d..9ceec0e 100644 --- a/src/support/steps/general.ts +++ b/src/support/steps/general.ts @@ -181,7 +181,7 @@ When('theme is activated', async function (this:ICustomWorld) { /** * Executes the step visit a page in mobile view. */ -When('visit page {string} in mobile view', async function (this:ICustomWorld, page) { +When('I visit {string} in mobile view', async function (this:ICustomWorld, page) { await this.page.setViewportSize({ width: 500, height: 480, diff --git a/src/support/steps/lcp-beacon-script.ts b/src/support/steps/lcp-beacon-script.ts new file mode 100644 index 0000000..ee84837 --- /dev/null +++ b/src/support/steps/lcp-beacon-script.ts @@ -0,0 +1,130 @@ +/** + * @fileoverview + * This module contains Cucumber step definitions using Playwright for deleting the WP Rocket plugin. + * It includes steps for confirming the deletion, navigating to the plugins page, deactivating the plugin, + * handling deactivation modal, initiating the deletion process, and asserting successful deletion. + * + * @requires {@link ../../common/custom-world} + * @requires {@link @playwright/test} + * @requires {@link @cucumber/cucumber} + */ +import { ICustomWorld } from "../../common/custom-world"; +import { expect } from "@playwright/test"; +import { Given, Then } from "@cucumber/cucumber"; +import { LcpDataTable, LcpData, Row } from "../../../utils/types"; +import axios from 'axios'; +import { dbQuery, getWPTablePrefix } from "../../../utils/commands"; +import { extractFromStdout } from "../../../utils/helpers"; +import { WP_BASE_URL } from '../../../config/wp.config'; + +let data: LcpDataTable[], + truthy: boolean = true, + failMsg: string = ""; + +const [actual, expected]: [LcpData, LcpData] = [{}, {}]; + +/** + * Executes step to visit page based on the form factor(desktop/mobile) and get the LCP/ATF data from DB. + */ +Given('I visit the following urls in {string}', async function (this: ICustomWorld, formFactor: string, dataTable) { + let sql: string, + result: string, + resultFromStdout: Row[]; + + // Set page to be visited in mobile. + if ( formFactor === 'mobile' ) { + await this.page.setViewportSize({ + width: 360, + height: 640, + }); + } + + data = dataTable.rows(); + + const tablePrefix: string = await getWPTablePrefix(); + + // Visit page. + for (const row of data) { + const url: string = `${WP_BASE_URL}/${row[0]}`; + await this.utils.visitPage(row[0]); + // Wait for 2 seconds before fetching from DB. + await this.page.waitForTimeout(2000); + + // Get the LCP/ATF from the DB + sql = `SELECT lcp, viewport FROM ${tablePrefix}wpr_above_the_fold WHERE url LIKE "%${row[0]}%"`; + result = await dbQuery(sql); + resultFromStdout = await extractFromStdout(result); + + // Populate the actual data. + actual[row[0]] = { + url: url, + lcp: resultFromStdout[0].lcp, + viewport: resultFromStdout[0].viewport + } + } +}); + +/** + * Executes the step to assert that LCP & ATF should be as expected. + */ +Then('lcp and atf should be as expected in {string}', async function (this: ICustomWorld, formFactor: string) { + let apiUrl: string; + + // Get the LCP from the PSI + for (const row of data) { + const url: string = `${WP_BASE_URL}/${row[0]}`; + apiUrl = `https://www.googleapis.com/pagespeedonline/v5/runPagespeed?url=${encodeURIComponent(url+'?nowprocket')}/&fields=lighthouseResult.audits&strategy=${formFactor}`; + + try { + const response = await axios.get(apiUrl); + const data = response.data; + const lcp: string = data.lighthouseResult.audits['prioritize-lcp-image'] && data.lighthouseResult.audits['prioritize-lcp-image'].details ? data.lighthouseResult.audits['prioritize-lcp-image'].details.debugData.initiatorPath[0].url : ''; + + // Populate the expected data. + expected[row[0]] = { + url: url, + lcp: lcp, + viewport: row[1] + } + } catch (error) { + console.error(`Error fetching PageSpeed Insight for ${url}:`, error); + } + } + + // Make assertions. + for (const key in actual) { + if (Object.hasOwnProperty.call(actual, key)) { + const [url, actualLcp, expectedLcp, actualViewport, expectedViewport] = [actual[key].url, actual[key].lcp, expected[key].lcp, actual[key].viewport, expected[key].viewport]; + + // Check if expected lcp is present in actual lcp. + if (!actualLcp.includes(expectedLcp)) { + truthy = false; + failMsg += `Expected LCP - ${expectedLcp} for ${url} is not present in actual - ${actualLcp}\n\n\n`; + } + + // Cater for multiple expected viewport candidates. + if (expectedViewport.includes(',')) { + const viewports = expectedViewport.split(',').map(item => item.trim()); + + for (const viewport of viewports) { + if (!actualViewport.includes(viewport)) { + truthy = false; + failMsg += `Expected Viewport - ${viewport} for ${url} is not present in actual - ${actualViewport}\n\n\n`; + } + } + // Treat single viewport candidate. + } else{ + if (!actualViewport.includes(expectedViewport)) { + truthy = false; + failMsg += `Expected Viewport - ${expectedViewport} for ${url} is not present in actual - ${actualViewport}\n\n\n`; + } + } + } + } + + if ( failMsg !== '' ) { + console.log(failMsg); + } + + expect(truthy, failMsg).toBeTruthy(); +}); \ No newline at end of file diff --git a/utils/commands.ts b/utils/commands.ts index d84a533..4cc0d48 100644 --- a/utils/commands.ts +++ b/utils/commands.ts @@ -317,4 +317,56 @@ export async function generateUsers(users: Array<{name: string, email: string, r }) } +/** + * Wraps a command with the appropriate prefix for SSH. + * + * @param {string} command - The command to be wrapped. + * @returns {string} - The wrapped command. + */ +function wrapSSHPrefix(command: string): string { + const cwd = getWPDir(configurations); + + if(configurations.type === ServerType.external) { + return `ssh ${configurations.ssh.username}@${configurations.ssh.address} -i ${configurations.ssh.key} -t "cd ${cwd} && ${command}"`; + } +} + +/** + * Performs a sql query using wp cli. + * + * @param {string} sql SQL Query. + * @return {Promise} A Promise that resolves when the query is executed. + */ +export async function dbQuery(sql: string): Promise { + sql = sql.replaceAll('"', '\\"'); + + const command = wrapSSHPrefix(`wp db query '${sql}'`); + const result = exec(command, { silent: true }); + + if (result.code === 1) { + return ''; + } + + return result.stdout; +} + +/** + * Gets the WordPress Table Prefix. + * + * @return {Promise} A Promise that resolves when the query is executed. + */ +export async function getWPTablePrefix(): Promise { + const command = wrapSSHPrefix(`wp config get table_prefix`); + const result = exec(command, { silent: true }); + + if (result.code === 1) { + return ''; + } + + let tablePrefix: string = result.stdout; + tablePrefix = tablePrefix.replace(/\r?\n/g, ""); + + return tablePrefix; +} + export default wp; \ No newline at end of file diff --git a/utils/helpers.ts b/utils/helpers.ts index 4b3aa5c..df79bea 100644 --- a/utils/helpers.ts +++ b/utils/helpers.ts @@ -16,7 +16,7 @@ import type { Page } from '@playwright/test'; import backstop from 'backstopjs'; // Interfaces -import { ExportedSettings, VRurlConfig, Viewport } from '../utils/types'; +import { ExportedSettings, VRurlConfig, Viewport, Row } from '../utils/types'; import { uiReflectedSettings } from './exclusions'; import { WP_BASE_URL } from '../config/wp.config'; @@ -367,3 +367,32 @@ export const deleteFolder = async(folderPath: string): Promise => { console.error(`Error deleting folder "${folderPath}": ${error.message}`); } } + +export const extractFromStdout = async(data: string): Promise => { + // Split the data into lines + const lines: string[] = data.trim().split('\n'); + + // Extract headers from the first line + const headers: string[] = lines[0].split('\t'); + + // Initialize an array to store objects + const result: Row[] = []; + + // Iterate over the remaining lines and create objects + for (let i: number = 1; i < lines.length; i++) { + const values: string[] = lines[i].split('\t'); + const obj: Row = {} as Row; + for (let j: number = 0; j < headers.length; j++) { + // Use keyof Row to ensure type safety + let value: string = values[j]; + // Perform multiple replacements until no more backslashes are found + while (value.includes('\\\\')) { + value = value.replace(/\\\\/g, ''); + } + obj[headers[j] as keyof Row] = value; + } + result.push(obj); + } + + return result; +} diff --git a/utils/types.ts b/utils/types.ts index ef5c098..8cfd687 100644 --- a/utils/types.ts +++ b/utils/types.ts @@ -98,4 +98,20 @@ export type Viewport = { name: string; width: number; height: number; -}; \ No newline at end of file +}; + +export interface LcpDataTable { + key: string; + value: string; +} +export interface LcpData { + [key: string]: { + url: string, + lcp: string, + viewport: string + } +} + +export interface Row { + [key: string]: string +} \ No newline at end of file