diff --git a/projects/plugins/boost/app/lib/class-cli.php b/projects/plugins/boost/app/lib/class-cli.php index 95a20f7e9d7f0..5f62685d8e494 100644 --- a/projects/plugins/boost/app/lib/class-cli.php +++ b/projects/plugins/boost/app/lib/class-cli.php @@ -60,18 +60,14 @@ public function reset_settings() { * * ## EXAMPLES * - * wp jetpack module activate critical-css - * wp jetpack module deactivate critical-css + * wp jetpack-boost module activate critical-css + * wp jetpack-boost module deactivate critical-css * * @param array $args Command arguments. */ public function module( $args ) { $action = isset( $args[0] ) ? $args[0] : null; - if ( ! $action ) { - \WP_CLI::error( __( 'Please specify a valid action.', 'jetpack-boost' ) ); - } - $module_slug = null; if ( isset( $args[1] ) ) { @@ -83,7 +79,8 @@ public function module( $args ) { ); } } else { - \WP_CLI::error( __( 'Please specify a valid module.', 'jetpack-boost' ) ); + /* translators: Placeholder is list of available modules. */ + \WP_CLI::error( sprintf( __( 'Please specify a valid module. It should be one of %s', 'jetpack-boost' ), wp_json_encode( Jetpack_Boost::AVAILABLE_MODULES_DEFAULT ) ) ); } switch ( $action ) { @@ -113,4 +110,74 @@ private function set_module_status( $module_slug, $status ) { sprintf( __( "'%1\$s' has been %2\$s.", 'jetpack-boost' ), $module_slug, $status_label ) ); } + + /** + * Manage Jetpack Boost connection + * + * ## OPTIONS + * + * + * : The action to take. + * --- + * options: + * - activate + * - deactivate + * - status + * --- + * + * ## EXAMPLES + * + * wp jetpack-boost connection activate + * wp jetpack-boost connection deactivate + * wp jetpack-boost connection status + * + * @param array $args Command arguments. + */ + public function connection( $args ) { + $action = isset( $args[0] ) ? $args[0] : null; + + switch ( $action ) { + case 'activate': + $result = $this->jetpack_boost->connection->register(); + if ( true === $result ) { + \WP_CLI::success( __( 'Boost is connected to WP.com', 'jetpack-boost' ) ); + } else { + \WP_CLI::Error( __( 'Boost could not be connected to WP.com', 'jetpack-boost' ) ); + } + break; + case 'deactivate': + require_once ABSPATH . '/wp-admin/includes/plugin.php'; + + if ( is_plugin_active_for_network( JETPACK_BOOST_PATH ) ) { + $this->jetpack_boost->connection->deactivate_disconnect_network(); + } else { + $this->jetpack_boost->connection->disconnect(); + } + + \WP_CLI::success( __( 'Boost is disconnected from WP.com', 'jetpack-boost' ) ); + break; + case 'status': + $is_connected = $this->jetpack_boost->connection->is_connected(); + if ( $is_connected ) { + \WP_CLI::line( 'connected' ); + } else { + \WP_CLI::line( 'disconnected' ); + } + break; + } + } + + /** + * Reset Jetpack Boost + * + * ## EXAMPLE + * + * wp jetpack-boost reset + */ + public function reset() { + $this->jetpack_boost->deactivate(); + $this->jetpack_boost->uninstall(); + $this->jetpack_boost->config()->reset(); + \WP_CLI::success( 'Reset successfully' ); + } } diff --git a/projects/plugins/boost/changelog/add-boost-e2e-tests b/projects/plugins/boost/changelog/add-boost-e2e-tests new file mode 100644 index 0000000000000..efd51fbbe3074 --- /dev/null +++ b/projects/plugins/boost/changelog/add-boost-e2e-tests @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Refactor and add new core E2E tests diff --git a/projects/plugins/boost/tests/e2e/README.md b/projects/plugins/boost/tests/e2e/README.md index 602bb63519bdc..3e3210ad814d7 100644 --- a/projects/plugins/boost/tests/e2e/README.md +++ b/projects/plugins/boost/tests/e2e/README.md @@ -26,9 +26,24 @@ To decrypt the config file (a8c only): Typically, the workflow is the same as the one described in the Jetpack E2E [documentation](../../../jetpack/tests/e2e/README.md). You can follow the same workflow but running the commands inside the Jetpack Boost E2E tests folder. -However, Boost has some shortcuts to get the environment started and run all the tests by running the following commands from the root of the Jetpack Boost repository: +However,below is a quick reminder of the critical steps to run the tests. -- `pnpm test-e2e:start` - This will command will start the e2e testing environment and the tunnel. +From the root of the repo (this has to be done only once or when pulling new changes): + +1. run `pnpm install` - This command will install the monorepo NPM dependencies. +2. run `jetpack build plugins/jetpack` - This command will install Jetpack NPM and Composer dependencies as well as building the asset files. +3. run `jetpack build plugins/boost` - This command will install Jetpack Boost NPM and Composer dependencies as well as building the asset files. + +From the `projects/plugins/boost/tests/e2e` folder: + +4. run `pnpm install` - This will install the Jetpack Boost E2E tests NPM dependencies. +5. run `pnpm run test-decrypt-config` - This command will decrypt the config and create/overwrite the local test config file . +6. run `pnpm run env-start && pnpm run tunnel-on` - This command will start the e2e testing environment and the tunnel. +7. run `pnpm run test-e2e` - This command will run the e2e tests. + +However, Boost has some shortcuts to get the environment started and run all the tests by running the following commands from the root of the Jetpack Boost folder: + +- `pnpm test-e2e:decrypt-config` - This command will decrypt the config and create/overwrite the local test config file . +- `pnpm test-e2e:start` - This command will start the e2e testing environment and the tunnel. - `pnpm test-e2e:run` - This command will run the e2e tests. - `pnpm test-e2e:stop` - This command will stop the e2e testing environment. -- `pnpm test-e2e:decrypt-config` - This command will decrypt the config and create/overwrite the local test config file . diff --git a/projects/plugins/boost/tests/e2e/lib/env/prerequisites.js b/projects/plugins/boost/tests/e2e/lib/env/prerequisites.js index 48b6e9b5d766e..cd6bbf9366dba 100644 --- a/projects/plugins/boost/tests/e2e/lib/env/prerequisites.js +++ b/projects/plugins/boost/tests/e2e/lib/env/prerequisites.js @@ -1,10 +1,16 @@ import logger from 'jetpack-e2e-commons/logger.cjs'; import { execWpCommand } from 'jetpack-e2e-commons/helpers/utils-helper.cjs'; + import { expect } from '@playwright/test'; +import { JetpackBoostPage } from '../pages/index.js'; -export function boostPrerequisitesBuilder() { +export function boostPrerequisitesBuilder( page ) { const state = { + testPostTitles: [], + clean: undefined, modules: { active: undefined, inactive: undefined }, + connected: undefined, + jetpackDeactivated: undefined, }; return { @@ -16,15 +22,30 @@ export function boostPrerequisitesBuilder() { state.modules.inactive = modules; return this; }, + withConnection( shouldBeConnected ) { + state.connected = shouldBeConnected; + return this; + }, + withTestContent( testPostTitles = [] ) { + state.testPostTitles = testPostTitles; + return this; + }, + withCleanEnv() { + state.clean = true; + return this; + }, async build() { - await buildPrerequisites( state ); + await buildPrerequisites( state, page ); }, }; } -async function buildPrerequisites( state ) { +async function buildPrerequisites( state, page ) { const functions = { modules: () => ensureModulesState( state.modules ), + connected: () => ensureConnectedState( state.connected, page ), + testPostTitles: () => ensureTestPosts( state.testPostTitles ), + clean: () => ensureCleanState( state.clean ), }; logger.prerequisites( JSON.stringify( state, null, 2 ) ); @@ -54,19 +75,91 @@ export async function ensureModulesState( modules ) { logger.prerequisites( 'Cannot find list of modules to deactivate!' ); } } - -export async function activateModules( modulesList ) { - for ( const module of modulesList ) { +export async function activateModules( modules ) { + for ( const module of modules ) { logger.prerequisites( `Activating module ${ module }` ); const result = await execWpCommand( `jetpack-boost module activate ${ module }` ); expect( result ).toMatch( new RegExp( `Success: .* has been activated.`, 'i' ) ); } } -export async function deactivateModules( modulesList ) { - for ( const module of modulesList ) { +export async function deactivateModules( modules ) { + for ( const module of modules ) { logger.prerequisites( `Deactivating module ${ module }` ); const result = await execWpCommand( `jetpack-boost module deactivate ${ module }` ); expect( result ).toMatch( new RegExp( `Success: .* has been deactivated.`, 'i' ) ); } } + +export async function ensureConnectedState( requiredConnected = undefined, page ) { + const isConnected = await checkIfConnected(); + + if ( requiredConnected && isConnected ) { + logger.prerequisites( 'Jetpack Boost is already connected, moving on' ); + } else if ( requiredConnected && ! isConnected ) { + logger.prerequisites( 'Connecting Jetpack Boost' ); + await connect( page ); + } else if ( ! requiredConnected && isConnected ) { + logger.prerequisites( 'Disconnecting Jetpack Boost' ); + await disconnect(); + } else { + logger.prerequisites( 'Jetpack Boost is already disconnected, moving on' ); + } +} + +export async function connect( page ) { + logger.prerequisites( `Connecting Boost plugin to WP.com` ); + // Boost cannot be connected to WP.com using the WP-CLI because the site is considered + // as a localhost site. The only solution is to do it via the site itself running under the localtunnel. + const jetpackBoostPage = await JetpackBoostPage.visit( page ); + await jetpackBoostPage.connect(); + await jetpackBoostPage.waitForApiResponse( 'connection' ); + await jetpackBoostPage.isOverallScoreHeaderShown(); +} + +export async function disconnect() { + logger.prerequisites( `Disconnecting Boost plugin to WP.com` ); + const cliCmd = 'jetpack-boost connection deactivate'; + const result = await execWpCommand( cliCmd ); + expect( result ).toEqual( 'Success: Boost is disconnected from WP.com' ); +} + +export async function checkIfConnected() { + const cliCmd = 'jetpack-boost connection status'; + const result = await execWpCommand( cliCmd ); + if ( typeof result !== 'object' ) { + return result === 'connected'; + } + const txt = result.toString(); + if ( txt.includes( "Error: 'jetpack-boost' is not a registered wp command" ) ) { + return false; + } + throw result; +} + +async function ensureTestPosts( testPostTitles ) { + const testPostTitlesCommands = { + 'Hello World with image': + "post create --post_status='publish' --post_title='Hello World with image' --post_content='

Hello World with image

This is just a test post with an image

\"placeholder
'", + 'Hello World with JavaScript': + 'post create --post_status=\'publish\' --post_title=\'Hello World with JavaScript\' --post_content=\'

Hello World with JavaScript

\'', + }; + for ( const testPostTitle of testPostTitles ) { + if ( testPostTitle in testPostTitlesCommands ) { + const result = await execWpCommand( 'post list --fields=post_title' ); + if ( result.includes( testPostTitle ) ) { + logger.prerequisites( 'The test content post already exists' ); + } else { + logger.prerequisites( 'Creating test content post...' ); + await execWpCommand( testPostTitlesCommands[ testPostTitle ] ); + } + } + } +} + +async function ensureCleanState( shouldReset ) { + if ( shouldReset ) { + logger.prerequisites( 'Resetting Jetpack Boost' ); + await execWpCommand( 'jetpack-boost reset' ); + } +} diff --git a/projects/plugins/boost/tests/e2e/lib/pages/Homepage.js b/projects/plugins/boost/tests/e2e/lib/pages/Homepage.js deleted file mode 100644 index c5dd26e5eb2cf..0000000000000 --- a/projects/plugins/boost/tests/e2e/lib/pages/Homepage.js +++ /dev/null @@ -1,9 +0,0 @@ -import WpPage from 'jetpack-e2e-commons/pages/wp-page.js'; -import { resolveSiteUrl } from 'jetpack-e2e-commons/helpers/utils-helper.cjs'; - -export default class Homepage extends WpPage { - constructor( page ) { - const url = `${ resolveSiteUrl() }`; - super( page, { expectedSelectors: [ '.home' ], url } ); - } -} diff --git a/projects/plugins/boost/tests/e2e/lib/pages/index.js b/projects/plugins/boost/tests/e2e/lib/pages/index.js new file mode 100644 index 0000000000000..589048da796ce --- /dev/null +++ b/projects/plugins/boost/tests/e2e/lib/pages/index.js @@ -0,0 +1 @@ +export { default as JetpackBoostPage } from './wp-admin/JetpackBoostPage.js'; diff --git a/projects/plugins/boost/tests/e2e/lib/pages/wp-admin/JetpackBoostPage.js b/projects/plugins/boost/tests/e2e/lib/pages/wp-admin/JetpackBoostPage.js index 8e50ffb316eaa..203a7b17685a3 100644 --- a/projects/plugins/boost/tests/e2e/lib/pages/wp-admin/JetpackBoostPage.js +++ b/projects/plugins/boost/tests/e2e/lib/pages/wp-admin/JetpackBoostPage.js @@ -3,6 +3,8 @@ import { resolveSiteUrl } from 'jetpack-e2e-commons/helpers/utils-helper.cjs'; const apiEndpointsRegex = { 'critical-css-status': /jetpack-boost\/v1\/module\/critical-css\/status/, + 'lazy-images-status': /jetpack-boost\/v1\/module\/lazy-images\/status/, + 'render-blocking-js-status': /jetpack-boost\/v1\/module\/render-blocking-js\/status/, 'speed-scores-update': /jetpack-boost\/v1\/speed-scores\/\w*\/update/, }; @@ -12,6 +14,27 @@ export default class JetpackBoostPage extends WpPage { super( page, { expectedSelectors: [ '#jb-settings' ], url } ); } + async connect() { + const button = await this.page.$( '.jb-connection button' ); + await button.click(); + } + + async isFreshlyConnected() { + await this.connect(); + await this.waitForApiResponse( 'connection' ); + return await this.isSiteScoreLoading(); + } + + async isOverallScoreHeaderShown() { + return await this.isElementVisible( '.jb-site-score' ); + } + + async isSiteScoreLoading() { + const selector = await this.waitForElementToBeVisible( '.jb-site-score' ); + const classNames = await selector.getAttribute( 'class' ); + return classNames.includes( 'loading' ); + } + async waitForApiResponse( apiEndpointId ) { await this.page.waitForResponse( response => @@ -41,4 +64,37 @@ export default class JetpackBoostPage extends WpPage { } ); return Number( await speedBar.$eval( '.jb-score-bar__score', e => e.textContent ) ); } + + async isTheCriticalCssMetaInformationVisible() { + const selector = '.jb-critical-css__meta'; + return this.page.isVisible( selector ); + } + + async waitForCriticalCssMetaInfoVisibility() { + const selector = '.jb-critical-css__meta'; + return this.waitForElementToBeVisible( selector, 3 * 60 * 1000 ); + } + + async waitForCriticalCssGenerationProgressUIVisibility() { + const selector = '.jb-critical-css-progress'; + return this.waitForElementToBeVisible( selector ); + } + + async isTheCriticalCssFailureMessageVisible() { + const selector = '.jb-critical-css__meta .failures'; + return this.page.isVisible( selector ); + } + + async navigateToCriticalCSSAdvancedRecommendations() { + await this.page.click( 'text=Advanced Recommendations' ); + } + + async isCriticalCSSAdvancedRecommendationsVisible() { + const selector = '.jb-critical-css__advanced'; + return this.waitForElementToBeVisible( selector ); + } + + async navigateToMainSettingsPage() { + await this.page.click( 'text=Go back' ); + } } diff --git a/projects/plugins/boost/tests/e2e/lib/setupTests.js b/projects/plugins/boost/tests/e2e/lib/setupTests.js index 90f74476d5894..6386417e40396 100644 --- a/projects/plugins/boost/tests/e2e/lib/setupTests.js +++ b/projects/plugins/boost/tests/e2e/lib/setupTests.js @@ -1,8 +1,11 @@ import { chromium } from '@playwright/test'; import { prerequisitesBuilder } from 'jetpack-e2e-commons/env/prerequisites.js'; +import { boostPrerequisitesBuilder } from './env/prerequisites.js'; export default async function () { const browser = await chromium.launch(); const page = await browser.newPage(); - await prerequisitesBuilder( page ).withLoggedIn( true ).withConnection( true ).build(); + await prerequisitesBuilder( page ).withLoggedIn( true ).withActivePlugins( [ 'boost' ] ).build(); + await boostPrerequisitesBuilder( page ).withCleanEnv( true ).withConnection( true ).build(); + await page.close(); } diff --git a/projects/plugins/boost/tests/e2e/specs/common.test.js b/projects/plugins/boost/tests/e2e/specs/common.test.js new file mode 100644 index 0000000000000..5ce881b0c2d8a --- /dev/null +++ b/projects/plugins/boost/tests/e2e/specs/common.test.js @@ -0,0 +1,56 @@ +import { test, expect } from '../fixtures/base-test.js'; +import { DashboardPage, PluginsPage, Sidebar } from 'jetpack-e2e-commons/pages/wp-admin/index.js'; +import { execWpCommand } from 'jetpack-e2e-commons/helpers/utils-helper.cjs'; +import { boostPrerequisitesBuilder } from '../lib/env/prerequisites.js'; +import { prerequisitesBuilder } from 'jetpack-e2e-commons/env/prerequisites.js'; +import { JetpackBoostPage } from '../lib/pages/index.js'; +import playwrightConfig from 'jetpack-e2e-commons/playwright.config.cjs'; + +test.afterAll( async ( { browser } ) => { + const page = await browser.newPage( playwrightConfig.use ); + + await prerequisitesBuilder( page ).withActivePlugins( [ 'boost' ] ).build(); + await boostPrerequisitesBuilder( page ).withConnection( true ).build(); + await page.close(); +} ); + +test( 'Click on the plugins page should navigate to Boost settings page', async ( { page } ) => { + await DashboardPage.visit( page ); + await ( await Sidebar.init( page ) ).selectInstalledPlugins(); + await ( await PluginsPage.init( page ) ).clickOnJetpackBoostSettingsLink(); + expect( await page.url() ).toContain( 'page=jetpack-boost' ); +} ); + +test( 'Click on the sidebar Boost Jetpack submenu should navigate to Boost settings page', async ( { + page, +} ) => { + await DashboardPage.visit( page ); + await ( await Sidebar.init( page ) ).selectJetpackBoost(); + expect( await page.url() ).toContain( 'page=jetpack-boost' ); +} ); + +test( 'Deactivating the plugin should clear Critical CSS and Dismissed Recommendation notice option', async ( { + page, +} ) => { + // Generate Critical CSS to ensure that on plugin deactivation it is cleared. + // TODO: Also should make sure that a Critical CSS recommendation is dismissed to check that the options does not exist after deactivation of the plugin. + await boostPrerequisitesBuilder( page ) + .withCleanEnv( true ) + .withActiveModules( [ 'critical-css' ] ) + .build(); + const jetpackBoostPage = await JetpackBoostPage.visit( page ); + expect( await jetpackBoostPage.waitForCriticalCssGenerationProgressUIVisibility() ).toBeTruthy(); + expect( await jetpackBoostPage.waitForCriticalCssMetaInfoVisibility() ).toBeTruthy(); + await DashboardPage.visit( page ); + await ( await Sidebar.init( page ) ).selectInstalledPlugins(); + await ( await PluginsPage.init( page ) ).deactivatePlugin( 'jetpack-boost' ); + let result; + result = await execWpCommand( + 'db query \'SELECT ID FROM wp_posts WHERE post_type LIKE "%jb_store_%"\' --skip-column-names' + ); + expect( result.length ).toBe( 0 ); + result = await execWpCommand( + 'db query \'SELECT option_id FROM wp_options WHERE option_name = "jb-critical-css-dismissed-recommendations"\' --skip-column-names' + ); + expect( result.length ).toBe( 0 ); +} ); diff --git a/projects/plugins/boost/tests/e2e/specs/connection.test.js b/projects/plugins/boost/tests/e2e/specs/connection.test.js new file mode 100644 index 0000000000000..dbce9a70d2812 --- /dev/null +++ b/projects/plugins/boost/tests/e2e/specs/connection.test.js @@ -0,0 +1,32 @@ +import { prerequisitesBuilder } from 'jetpack-e2e-commons/env/prerequisites.js'; +import { test, expect } from '../fixtures/base-test.js'; +import { JetpackBoostPage } from '../lib/pages/index.js'; +import { boostPrerequisitesBuilder } from '../lib/env/prerequisites.js'; + +test.describe( 'Settings Page Connection', () => { + test( 'Should connect to WP.com on a fresh install with Jetpack plugin activated and Jetpack already connected', async ( { + page, + } ) => { + await prerequisitesBuilder().withActivePlugins( [ 'jetpack' ] ).withConnection( true ).build(); + await boostPrerequisitesBuilder().withConnection( false ).build(); + const jetpackBoostPage = await JetpackBoostPage.visit( page ); + expect( await jetpackBoostPage.isFreshlyConnected() ).toBeTruthy(); + } ); + + test( 'Should connect to WP.com on a fresh install with Jetpack plugin activated', async ( { + page, + } ) => { + await boostPrerequisitesBuilder( page ).withCleanEnv( true ).withConnection( false ).build(); + const jetpackBoostPage = await JetpackBoostPage.visit( page ); + expect( await jetpackBoostPage.isFreshlyConnected() ).toBeTruthy(); + } ); + + test( 'Should connect to WP.com on a fresh install without Jetpack plugin activated', async ( { + page, + } ) => { + await prerequisitesBuilder().withInactivePlugins( [ 'jetpack' ] ).build(); + await boostPrerequisitesBuilder( page ).withCleanEnv( true ).withConnection( false ).build(); + const jetpackBoostPage = await JetpackBoostPage.visit( page ); + expect( await jetpackBoostPage.isFreshlyConnected() ).toBeTruthy(); + } ); +} ); diff --git a/projects/plugins/boost/tests/e2e/specs/critical-css.test.js b/projects/plugins/boost/tests/e2e/specs/critical-css.test.js new file mode 100644 index 0000000000000..31202b9a6a636 --- /dev/null +++ b/projects/plugins/boost/tests/e2e/specs/critical-css.test.js @@ -0,0 +1,115 @@ +import { test, expect } from '../fixtures/base-test.js'; +import { boostPrerequisitesBuilder } from '../lib/env/prerequisites.js'; +import { JetpackBoostPage } from '../lib/pages/index.js'; +import { PostFrontendPage } from 'jetpack-e2e-commons/pages/index.js'; +import { DashboardPage, ThemesPage, Sidebar } from 'jetpack-e2e-commons/pages/wp-admin/index.js'; +import playwrightConfig from 'jetpack-e2e-commons/playwright.config.cjs'; + +test.describe( 'Critical CSS module', () => { + let page; + + test.beforeAll( async ( { browser } ) => { + page = await browser.newPage( playwrightConfig.use ); + await boostPrerequisitesBuilder( page ).withCleanEnv( true ).withConnection( true ).build(); + } ); + + test.afterAll( async ( { browser } ) => { + page = await browser.newPage(); + await DashboardPage.visit( page ); + await ( await Sidebar.init( page ) ).selectThemes(); + await ( await ThemesPage.init( page ) ).activateTheme( 'twentytwentyone' ); + await page.close(); + } ); + + // NOTE: The order of the following tests is important as we are making reuse of the generated Critical CSS + // which is an onerous task in a test. + + test( 'No Critical CSS meta information should show on the admin when the module is inactive', async () => { + await boostPrerequisitesBuilder( page ).withInactiveModules( [ 'critical-css' ] ).build(); + const jetpackBoostPage = await JetpackBoostPage.visit( page ); + expect( await jetpackBoostPage.isTheCriticalCssMetaInformationVisible() ).toBeFalsy(); + } ); + + test( 'No Critical CSS should be available on the frontend when the module is inactive', async () => { + await boostPrerequisitesBuilder( page ).withInactiveModules( [ 'critical-css' ] ).build(); + await PostFrontendPage.visit( page ); + expect( + await page.locator( '#jetpack-boost-critical-css' ).count( { + timeout: 5 * 1000, + } ) + ).toBe( 0 ); + } ); + + test( 'Critical CSS should be generated when the module is active', async () => { + await boostPrerequisitesBuilder( page ).withActiveModules( [ 'critical-css' ] ).build(); + const jetpackBoostPage = await JetpackBoostPage.visit( page ); + expect( + await jetpackBoostPage.waitForCriticalCssGenerationProgressUIVisibility() + ).toBeTruthy(); + expect( await jetpackBoostPage.waitForCriticalCssMetaInfoVisibility() ).toBeTruthy(); + } ); + + test( 'Critical CSS meta information should show on the admin when the module is re-activated', async () => { + await boostPrerequisitesBuilder( page ).withInactiveModules( [ 'critical-css' ] ).build(); + await boostPrerequisitesBuilder( page ).withActiveModules( [ 'critical-css' ] ).build(); + const jetpackBoostPage = await JetpackBoostPage.visit( page ); + expect( await jetpackBoostPage.waitForCriticalCssMetaInfoVisibility() ).toBeTruthy(); + } ); + + test( 'Critical CSS should be available on the frontend when the module is active', async () => { + await PostFrontendPage.visit( page ); + const criticalCss = await page.locator( '#jetpack-boost-critical-css' ).innerText(); + expect( criticalCss.length ).toBeGreaterThan( 100 ); + } ); + + test( 'Critical CSS Admin message should show when the theme is changed', async () => { + await boostPrerequisitesBuilder( page ).withActiveModules( [ 'critical-css' ] ).build(); + await DashboardPage.visit( page ); + await ( await Sidebar.init( page ) ).selectThemes(); + const themesPage = await ThemesPage.init( page ); + await ( await ThemesPage.init( page ) ).activateTheme( 'twentytwenty' ); + expect( await page.locator( 'text=Jetpack Boost - Action Required' ).isVisible() ).toBeTruthy(); + await themesPage.click( + '#jetpack-boost-notice-critical-css-regenerate a[href*="jetpack-boost"]' + ); + const jetpackBoostPage = await JetpackBoostPage.init( page ); + expect( + await jetpackBoostPage.waitForCriticalCssGenerationProgressUIVisibility() + ).toBeTruthy(); + expect( await jetpackBoostPage.waitForCriticalCssMetaInfoVisibility() ).toBeTruthy(); + } ); + + test( 'Critical CSS should be generated with an error (advanced recommendations)', async () => { + await boostPrerequisitesBuilder( page ) + .withCleanEnv( true ) + .withActiveModules( [ 'critical-css' ] ) + .build(); + + // Purposely fail some page requests so Critical CSS will be generated with an error, and we can + // test scenarios around advanced recommendations. + await page.route( '**/*', route => { + const url = route.request().url(); + if ( url.includes( 'page_id' ) ) { + return route.abort(); + } + return route.continue(); + } ); + + const jetpackBoostPage = await JetpackBoostPage.visit( page ); + expect( + await jetpackBoostPage.waitForCriticalCssGenerationProgressUIVisibility() + ).toBeTruthy(); + expect( await jetpackBoostPage.waitForCriticalCssMetaInfoVisibility() ).toBeTruthy(); + expect( await jetpackBoostPage.isTheCriticalCssFailureMessageVisible() ).toBeTruthy(); + } ); + + test( 'User can access the Critical advanced recommendations and go back to settings page', async () => { + await boostPrerequisitesBuilder( page ).withActiveModules( [ 'critical-css' ] ).build(); + + const jetpackBoostPage = await JetpackBoostPage.visit( page ); + await jetpackBoostPage.navigateToCriticalCSSAdvancedRecommendations(); + expect( await jetpackBoostPage.isCriticalCSSAdvancedRecommendationsVisible() ).toBeTruthy(); + await jetpackBoostPage.navigateToMainSettingsPage(); + expect( await jetpackBoostPage.isTheCriticalCssMetaInformationVisible() ).toBeTruthy(); + } ); +} ); diff --git a/projects/plugins/boost/tests/e2e/specs/homepage.test.js b/projects/plugins/boost/tests/e2e/specs/homepage.test.js deleted file mode 100644 index 5698f438d3bf5..0000000000000 --- a/projects/plugins/boost/tests/e2e/specs/homepage.test.js +++ /dev/null @@ -1,24 +0,0 @@ -import { test, expect } from '../fixtures/base-test.js'; -import Homepage from '../lib/pages/Homepage.js'; - -// TODO: This is for illustrative purpose only. It will need refactoring and improving. -test.describe( 'Homepage', () => { - test.beforeEach( async function ( { page } ) { - await Homepage.visit( page, false ); - } ); - - test( 'Should display "HelloWord" text on page', async ( { page } ) => { - await expect( page.locator( 'h1' ) ).toHaveText( 'HelloWord' ); - } ); - - test( 'Should include the jetpack boost meta tag(s)', async ( { page } ) => { - const metaTag = await page.$$( "//meta[@name='jetpack-boost-ready']" ); - expect( metaTag.length ).toBeGreaterThan( 0 ); - } ); - - // We need to properly wait for local css generation to be complete before we can re-enable this test - test.skip( 'Should be ready', async ( { page } ) => { - const metaTag = await page.$$( "//meta[@name='jetpack-boost-ready' and @content='true']" ); - expect( metaTag.length ).toBeGreaterThan( 0 ); - } ); -} ); diff --git a/projects/plugins/boost/tests/e2e/specs/lazy-images.test.js b/projects/plugins/boost/tests/e2e/specs/lazy-images.test.js new file mode 100644 index 0000000000000..000952b250218 --- /dev/null +++ b/projects/plugins/boost/tests/e2e/specs/lazy-images.test.js @@ -0,0 +1,37 @@ +import { test, expect } from '../fixtures/base-test.js'; +import { boostPrerequisitesBuilder } from '../lib/env/prerequisites.js'; +import { execWpCommand } from 'jetpack-e2e-commons/helpers/utils-helper.cjs'; +import { prerequisitesBuilder } from 'jetpack-e2e-commons/env/prerequisites.js'; +import { PostFrontendPage } from 'jetpack-e2e-commons/pages/index.js'; +import playwrightConfig from 'jetpack-e2e-commons/playwright.config.cjs'; + +const testPostTitle = 'Hello World with image'; + +test.describe( 'Lazy Images module', () => { + let page; + + test.beforeAll( async ( { browser } ) => { + page = await browser.newPage( playwrightConfig.use ); + await boostPrerequisitesBuilder( page ).withTestContent( [ testPostTitle ] ).build(); + await execWpCommand( 'user session destroy wordpress --all' ); + } ); + + test.afterAll( async () => { + await prerequisitesBuilder( page ).withLoggedIn( true ).build(); + await page.close(); + } ); + + test( 'Images on a post should not be lazy loaded when the module is inactive', async () => { + await boostPrerequisitesBuilder( page ).withInactiveModules( [ 'lazy-images' ] ).build(); + const frontend = await PostFrontendPage.visit( page ); + await frontend.click( `text=${ testPostTitle }` ); + expect( await page.locator( '.jetpack-lazy-image' ).count() ).toBe( 0 ); + } ); + + test( 'Images on a post should be lazy loaded when the module is active', async () => { + await boostPrerequisitesBuilder( page ).withActiveModules( [ 'lazy-images' ] ).build(); + const frontend = await PostFrontendPage.visit( page ); + await frontend.click( `text=${ testPostTitle }` ); + expect( await page.locator( '.jetpack-lazy-image' ).count() ).toBeGreaterThan( 0 ); + } ); +} ); diff --git a/projects/plugins/boost/tests/e2e/specs/jetpack-boost/modules-common.test.js b/projects/plugins/boost/tests/e2e/specs/modules-common.test.js similarity index 59% rename from projects/plugins/boost/tests/e2e/specs/jetpack-boost/modules-common.test.js rename to projects/plugins/boost/tests/e2e/specs/modules-common.test.js index 376700478410e..df29f09c70cb9 100644 --- a/projects/plugins/boost/tests/e2e/specs/jetpack-boost/modules-common.test.js +++ b/projects/plugins/boost/tests/e2e/specs/modules-common.test.js @@ -1,8 +1,7 @@ -import { test, expect } from '../../fixtures/base-test.js'; -import JetpackBoostPage from '../../lib/pages/wp-admin/JetpackBoostPage.js'; -import { boostPrerequisitesBuilder } from '../../lib/env/prerequisites.js'; - -let jetpackBoostPage; +import { boostPrerequisitesBuilder } from '../lib/env/prerequisites.js'; +import { test, expect } from '../fixtures/base-test.js'; +import { JetpackBoostPage } from '../lib/pages/index.js'; +import playwrightConfig from 'jetpack-e2e-commons/playwright.config.cjs'; const modules = [ // ['MODULE_NAME', 'DEFAULT STATE'], @@ -11,44 +10,41 @@ const modules = [ [ 'render-blocking-js', 'disabled' ], ]; -test.describe( 'Modules', () => { - test.beforeAll( async () => { - await boostPrerequisitesBuilder() +test.describe.serial( 'Modules', () => { + let page; + let jetpackBoostPage; + + test.beforeAll( async ( { browser } ) => { + page = await browser.newPage( playwrightConfig.use ); + + await boostPrerequisitesBuilder( page ) + .withConnection( true ) .withInactiveModules( [ 'critical-css', 'lazy-images', 'render-blocking-js' ] ) .build(); - } ); - - test.beforeEach( async ( { page } ) => { jetpackBoostPage = await JetpackBoostPage.visit( page ); } ); modules.forEach( ( [ moduleSlug, moduleState ] = module ) => { test( `The ${ moduleSlug } module should be ${ moduleState } by default`, async () => { - let isModuleEnabled = false; - if ( moduleState === 'enabled' ) { - isModuleEnabled = true; - } - expect( await jetpackBoostPage.isModuleEnabled( moduleSlug ) ).toEqual( isModuleEnabled ); + expect( await jetpackBoostPage.isModuleEnabled( moduleSlug ) ).toEqual( + moduleState === 'enabled' + ); } ); test( `The ${ moduleSlug } module state should toggle to an inverse state`, async () => { - let isModuleEnabled = true; - if ( moduleState === 'enabled' ) { - isModuleEnabled = false; - } await jetpackBoostPage.toggleModule( moduleSlug ); await jetpackBoostPage.waitForApiResponse( `${ moduleSlug }-status` ); - expect( await jetpackBoostPage.isModuleEnabled( moduleSlug ) ).toEqual( isModuleEnabled ); + expect( await jetpackBoostPage.isModuleEnabled( moduleSlug ) ).toEqual( + moduleState !== 'enabled' + ); } ); test( `The ${ moduleSlug } module state should revert back to original state`, async () => { - let isModuleEnabled = false; - if ( moduleState === 'enabled' ) { - isModuleEnabled = true; - } await jetpackBoostPage.toggleModule( moduleSlug ); await jetpackBoostPage.waitForApiResponse( `${ moduleSlug }-status` ); - expect( await jetpackBoostPage.isModuleEnabled( moduleSlug ) ).toEqual( isModuleEnabled ); + expect( await jetpackBoostPage.isModuleEnabled( moduleSlug ) ).toEqual( + moduleState === 'enabled' + ); } ); } ); } ); diff --git a/projects/plugins/boost/tests/e2e/specs/render-blocking-js.test.js b/projects/plugins/boost/tests/e2e/specs/render-blocking-js.test.js new file mode 100644 index 0000000000000..8ed6ffa19f33a --- /dev/null +++ b/projects/plugins/boost/tests/e2e/specs/render-blocking-js.test.js @@ -0,0 +1,48 @@ +import { test, expect } from '../fixtures/base-test.js'; +import { boostPrerequisitesBuilder } from '../lib/env/prerequisites.js'; +import { PostFrontendPage } from 'jetpack-e2e-commons/pages/index.js'; +import playwrightConfig from 'jetpack-e2e-commons/playwright.config.cjs'; + +const testPostTitle = 'Hello World with JavaScript'; + +test.describe( 'Render Blocking JS module', () => { + let page; + + test.beforeAll( async ( { browser } ) => { + page = await browser.newPage( playwrightConfig.use ); + await boostPrerequisitesBuilder( page ).withTestContent( [ testPostTitle ] ).build(); + } ); + + test( 'JavaScript on a post should be at its original position in the document when the module is inactive', async () => { + await boostPrerequisitesBuilder( page ).withInactiveModules( [ 'render-blocking-js' ] ).build(); + const frontend = await PostFrontendPage.visit( page ); + await frontend.click( `text=${ testPostTitle }` ); + // For this test we are checking if the JavaScript from the test content is still inside its original parent element + // which has the "render-blocking-js" class. + const script = await page.locator( '#blockingScript' ); + expect( + await script.evaluate( element => + element.parentElement.classList.contains( 'render-blocking-js' ) + ) + ).toBeTruthy(); + // Confirm that the JavaScript was executed. + await page.locator( '#testDiv' ).isHidden(); + } ); + + test( 'JavaScript on a post should be pushed at the bottom of the document when the module is active', async () => { + // Since the render blocking js module grab all JavaScript from a document and pushed it at the bottom of the DOM. + // For this test we are checking if the JavaScript from the test content is not anymore in its parent element. + // which has the "render-blocking-js" class. + await boostPrerequisitesBuilder( page ).withActiveModules( [ 'render-blocking-js' ] ).build(); + const frontend = await PostFrontendPage.visit( page ); + await frontend.click( `text=${ testPostTitle }` ); + const script = await page.locator( '#blockingScript' ); + expect( + await script.evaluate( element => + element.parentElement.classList.contains( 'render-blocking-js' ) + ) + ).toBeFalsy(); + // Confirm that the JavaScript was executed. + await page.locator( '#testDiv' ).isHidden(); + } ); +} ); diff --git a/projects/plugins/boost/tests/e2e/specs/jetpack-boost/speed-score.test.js b/projects/plugins/boost/tests/e2e/specs/speed-score.test.js similarity index 61% rename from projects/plugins/boost/tests/e2e/specs/jetpack-boost/speed-score.test.js rename to projects/plugins/boost/tests/e2e/specs/speed-score.test.js index 32d6b3d8606f2..7f7228982b923 100644 --- a/projects/plugins/boost/tests/e2e/specs/jetpack-boost/speed-score.test.js +++ b/projects/plugins/boost/tests/e2e/specs/speed-score.test.js @@ -1,5 +1,5 @@ -import { test, expect } from '../../fixtures/base-test.js'; -import JetpackBoostPage from '../../lib/pages/wp-admin/JetpackBoostPage.js'; +import { test, expect } from '../fixtures/base-test.js'; +import { JetpackBoostPage } from '../lib/pages/index.js'; let jetpackBoostPage; @@ -8,7 +8,7 @@ test.describe( 'Speed Score feature', () => { jetpackBoostPage = await JetpackBoostPage.visit( page ); } ); - test( 'Should display a mobile and desktop speed score greater than zero', async () => { + test( 'The Speed Score section should display a mobile and desktop speed score greater than zero', async () => { expect( await jetpackBoostPage.getSpeedScore( 'mobile' ) ).toBeGreaterThan( 0 ); expect( await jetpackBoostPage.getSpeedScore( 'desktop' ) ).toBeGreaterThan( 0 ); } ); diff --git a/tools/e2e-commons/env/prerequisites.js b/tools/e2e-commons/env/prerequisites.js index eed925ae2aea3..2a1a575fd9691 100644 --- a/tools/e2e-commons/env/prerequisites.js +++ b/tools/e2e-commons/env/prerequisites.js @@ -14,6 +14,7 @@ import assert from 'assert'; export function prerequisitesBuilder( page ) { const state = { clean: undefined, + plugins: { active: undefined, inactive: undefined }, loggedIn: undefined, wpComLoggedIn: undefined, connected: undefined, @@ -22,6 +23,14 @@ export function prerequisitesBuilder( page ) { }; return { + withActivePlugins( plugins = [] ) { + state.plugins.active = plugins; + return this; + }, + withInactivePlugins( plugins = [] ) { + state.plugins.inactive = plugins; + return this; + }, withLoggedIn( shouldBeLoggedIn ) { state.loggedIn = shouldBeLoggedIn; return this; @@ -58,6 +67,7 @@ export function prerequisitesBuilder( page ) { async function buildPrerequisites( state, page ) { const functions = { + plugins: () => ensurePluginsState( state.plugins ), loggedIn: () => ensureUserIsLoggedIn( page ), wpComLoggedIn: () => ensureWpComUserIsLoggedIn( page ), connected: () => ensureConnectedState( state.connected ), @@ -196,6 +206,57 @@ export async function deactivateModules( modulesList ) { } } +export async function ensurePluginsState( plugins ) { + if ( ! isLocalSite() ) { + logger.prerequisites( 'Site is not local, skipping plugins setup.' ); + return; + } + + if ( plugins.active ) { + await activatePlugins( plugins.active ); + } else { + logger.prerequisites( 'Cannot find list of plugins to activate!' ); + } + + if ( plugins.inactive ) { + await deactivatePlugins( plugins.inactive ); + } else { + logger.prerequisites( 'Cannot find list of plugins to deactivate!' ); + } +} + +async function activatePlugins( pluginsList ) { + const activatedPlugins = []; + for ( const plugin of pluginsList ) { + logger.prerequisites( `Activating plugin ${ plugin }` ); + const result = await execWpCommand( `plugin activate ${ plugin }` ); + const txt = result.toString(); + if ( + txt.includes( `Plugin '${ plugin }' activated.` ) || + txt.includes( `Plugin '${ plugin }' is already active.` ) + ) { + activatedPlugins.push( plugin ); + } + } + assert.equal( pluginsList.length, activatedPlugins.length ); +} + +async function deactivatePlugins( pluginsList ) { + const deactivatedPlugins = []; + for ( const plugin of pluginsList ) { + logger.prerequisites( `Deactivating plugin ${ plugin }` ); + const result = await execWpCommand( `plugin deactivate ${ plugin }` ); + const txt = result.toString(); + if ( + txt.includes( `Plugin '${ plugin }' deactivated.` ) || + txt.includes( `Plugin '${ plugin }' isn't active.` ) + ) { + deactivatedPlugins.push( plugin ); + } + } + assert.equal( pluginsList.length, deactivatedPlugins.length ); +} + export async function isBlogTokenSet() { const cliCmd = 'jetpack options get blog_token'; const result = await execWpCommand( cliCmd ); diff --git a/tools/e2e-commons/pages/postFrontend.js b/tools/e2e-commons/pages/postFrontend.js index 590b8ce2a3847..aba8856450dfe 100644 --- a/tools/e2e-commons/pages/postFrontend.js +++ b/tools/e2e-commons/pages/postFrontend.js @@ -1,8 +1,10 @@ import WpPage from './wp-page.js'; +import { resolveSiteUrl } from '../helpers/utils-helper.cjs'; export default class PostFrontendPage extends WpPage { constructor( page ) { - super( page, { expectedSelectors: [ '.post' ] } ); + const url = resolveSiteUrl(); + super( page, { expectedSelectors: [ '.post' ], url } ); } /** diff --git a/tools/e2e-commons/pages/wp-admin/index.js b/tools/e2e-commons/pages/wp-admin/index.js index 5c92bdf9f8835..3bca22f66ff53 100644 --- a/tools/e2e-commons/pages/wp-admin/index.js +++ b/tools/e2e-commons/pages/wp-admin/index.js @@ -11,5 +11,6 @@ export { default as InPlacePlansPage } from './in-place-plans.js'; export { default as JetpackPage } from './jetpack.js'; export { default as WPLoginPage } from './login.js'; export { default as PluginsPage } from './plugins.js'; +export { default as ThemesPage } from './themes.js'; export { default as RecommendationsPage } from './recommendations.js'; export { default as Sidebar } from './sidebar.js'; diff --git a/tools/e2e-commons/pages/wp-admin/login.js b/tools/e2e-commons/pages/wp-admin/login.js index 7099dd25c2f92..03413c4d2c74f 100644 --- a/tools/e2e-commons/pages/wp-admin/login.js +++ b/tools/e2e-commons/pages/wp-admin/login.js @@ -20,9 +20,8 @@ export default class WPLoginPage extends WpPage { await this.fill( '#user_login', credentials.username ); await this.fill( '#user_pass', credentials.password ); - const navigationPromise = this.waitForDomContentLoaded(); await this.click( '#wp-submit' ); - await navigationPromise; + await this.waitForDomContentLoaded(); try { await this.waitForElementToBeHidden( this.selectors[ 0 ] ); diff --git a/tools/e2e-commons/pages/wp-admin/plugins.js b/tools/e2e-commons/pages/wp-admin/plugins.js index ab96adb5f4375..c0c98a3cbb980 100644 --- a/tools/e2e-commons/pages/wp-admin/plugins.js +++ b/tools/e2e-commons/pages/wp-admin/plugins.js @@ -5,18 +5,16 @@ export default class PluginsPage extends WpPage { super( page, { expectedSelectors: [ '.search-box' ] } ); } - async deactivateJetpack() { - const selector = "tr[data-slug='jetpack'] a[href*='=deactivate']"; - const navigationPromise = this.waitForLoad(); + async deactivatePlugin( pluginSlug ) { + const selector = `tr[data-slug='${ pluginSlug }'] a[href*='=deactivate']`; await this.click( selector ); - await navigationPromise; + await this.waitForLoad(); } - async activateJetpack() { - const selector = "tr[data-slug='jetpack'] a[href*='=activate']"; - const navigationPromise = this.waitForLoad(); + async activatePlugin( pluginSlug ) { + const selector = `tr[data-slug='${ pluginSlug }'] a[href*='=activate']`; await this.click( selector ); - await navigationPromise; + await this.waitForLoad(); } async isFullScreenPopupShown() { @@ -48,4 +46,9 @@ export default class PluginsPage extends WpPage { await this.waitForElementToBeVisible( isUpdatingMessage ); await this.waitForElementToBeVisible( updatedMessage, 5 * 30000 ); } + + async clickOnJetpackBoostSettingsLink() { + const selector = "tr[data-slug='jetpack-boost'] .row-actions a[href*='=jetpack-boost']"; + return await this.page.click( selector ); + } } diff --git a/tools/e2e-commons/pages/wp-admin/sidebar.js b/tools/e2e-commons/pages/wp-admin/sidebar.js index 0908996a709d3..d5592cfcc3c3a 100644 --- a/tools/e2e-commons/pages/wp-admin/sidebar.js +++ b/tools/e2e-commons/pages/wp-admin/sidebar.js @@ -13,6 +13,13 @@ export default class Sidebar extends WpPage { return await this._selectMenuItem( jetpackMenuSelector, menuItemSelector ); } + async selectJetpackBoost() { + const jetpackMenuSelector = '#toplevel_page_jetpack'; + const menuItemSelector = '#toplevel_page_jetpack a[href$="jetpack-boost"]'; + + return await this._selectMenuItem( jetpackMenuSelector, menuItemSelector ); + } + async selectNewPost() { const postsSelector = '#menu-posts'; const itemSelector = '#menu-posts a[href*="post-new"]'; @@ -34,6 +41,13 @@ export default class Sidebar extends WpPage { return await this._selectMenuItem( mainSelector, itemSelector ); } + async selectThemes() { + const pluginsSelector = '#menu-appearance'; + const itemSelector = '#menu-appearance a[href*="themes.php"]'; + + return await this._selectMenuItem( pluginsSelector, itemSelector ); + } + async _selectMenuItem( menuSelector, menuItemSelector ) { const menuElement = await this.waitForElementToBeVisible( menuSelector ); const classes = await this.page.$eval( menuSelector, e => e.getAttribute( 'class' ) ); diff --git a/tools/e2e-commons/pages/wp-admin/themes.js b/tools/e2e-commons/pages/wp-admin/themes.js new file mode 100644 index 0000000000000..d5f94535e36e5 --- /dev/null +++ b/tools/e2e-commons/pages/wp-admin/themes.js @@ -0,0 +1,13 @@ +import WpPage from '../wp-page.js'; + +export default class ThemesPage extends WpPage { + constructor( page ) { + super( page, { expectedSelectors: [ '.search-form' ] } ); + } + + async activateTheme( themeSlug ) { + const selector = `div[data-slug='${ themeSlug }'] a[href*='=activate']`; + await this.click( selector ); + await this.waitForLoad(); + } +}