diff --git a/projects/plugins/boost/.phan/baseline.php b/projects/plugins/boost/.phan/baseline.php index 5a24e570c060..c0e5586731a6 100644 --- a/projects/plugins/boost/.phan/baseline.php +++ b/projects/plugins/boost/.phan/baseline.php @@ -12,8 +12,8 @@ // PhanTypeArraySuspiciousNullable : 15+ occurrences // PhanPluginDuplicateConditionalNullCoalescing : 10+ occurrences // PhanTypeArraySuspicious : 9 occurrences - // PhanTypeMismatchArgument : 6 occurrences // PhanUndeclaredConstant : 5 occurrences + // PhanTypeMismatchArgument : 4 occurrences // PhanTypeMismatchReturnProbablyReal : 4 occurrences // PhanUndeclaredClassConstant : 4 occurrences // PhanUndeclaredFunction : 4 occurrences @@ -32,7 +32,7 @@ 'app/admin/class-config.php' => ['PhanTypeMismatchArgument'], 'app/data-sync/class-minify-excludes-state-entry.php' => ['PhanTypeMismatchReturnProbablyReal'], 'app/data-sync/class-performance-history-entry.php' => ['PhanPluginDuplicateConditionalNullCoalescing', 'PhanTypeArraySuspicious'], - 'app/lib/class-cli.php' => ['PhanPluginDuplicateConditionalNullCoalescing', 'PhanTypeMismatchArgument'], + 'app/lib/class-cli.php' => ['PhanPluginDuplicateConditionalNullCoalescing'], 'app/lib/critical-css/class-critical-css-state.php' => ['PhanPluginDuplicateConditionalNullCoalescing', 'PhanTypeArraySuspiciousNullable'], 'app/lib/minify/class-concatenate-css.php' => ['PhanPluginUseReturnValueInternalKnown', 'PhanTypeMismatchArgument'], 'app/lib/minify/class-concatenate-js.php' => ['PhanPluginUseReturnValueInternalKnown', 'PhanTypeMismatchArgument'], diff --git a/projects/plugins/boost/app/lib/class-cli.php b/projects/plugins/boost/app/lib/class-cli.php index 494018c197ec..bfd0eac5eac1 100644 --- a/projects/plugins/boost/app/lib/class-cli.php +++ b/projects/plugins/boost/app/lib/class-cli.php @@ -28,7 +28,7 @@ class CLI { */ private $jetpack_boost; - const MAKE_E2E_TESTS_WORK_MODULES = array( 'critical_css', 'speculation_rules', 'render_blocking_js', 'page_cache', 'lcp', 'minify_js', 'minify_css', 'image_cdn', 'image_guide' ); + const MAKE_E2E_TESTS_WORK_MODULES = array( 'critical_css', 'cloud_css', 'speculation_rules', 'render_blocking_js', 'page_cache', 'lcp', 'minify_js', 'minify_css', 'image_cdn', 'image_guide' ); /** * CLI constructor. @@ -110,7 +110,7 @@ public function getting_started( $args ) { * Set a module status. * * @param string $module_slug Module slug. - * @param string $status Module status. + * @param bool $status Module status. */ private function set_module_status( $module_slug, $status ) { $modules = ( new Modules_Setup() )->get_available_modules_and_submodules(); @@ -122,7 +122,14 @@ private function set_module_status( $module_slug, $status ) { ); } - $module->update( $status ); + $updated = $module->update( $status ); + + // Fire the same action that the DataSync REST path fires (Modules_State_Entry::set), + // so CLI activation triggers module hooks like Cloud_CSS::activate() → Regenerate::start(). + // Only fire when the status actually changed, matching the DataSync guard. + if ( $updated ) { + do_action( 'jetpack_boost_module_status_updated', $module_slug, $status ); + } if ( $module_slug === 'page_cache' && $status ) { $setup_result = Page_Cache_Setup::run_setup(); @@ -142,10 +149,17 @@ private function set_module_status( $module_slug, $status ) { $status_label = $status ? __( 'activated', 'jetpack-boost' ) : __( 'deactivated', 'jetpack-boost' ); - /* translators: The %1$s refers to the module slug, %2$s refers to the module state (either activated or deactivated)*/ - \WP_CLI::success( - sprintf( __( "'%1\$s' has been %2\$s.", 'jetpack-boost' ), $module_slug, $status_label ) - ); + if ( $updated ) { + /* translators: The %1$s refers to the module slug, %2$s refers to the module state (either activated or deactivated)*/ + \WP_CLI::success( + sprintf( __( "'%1\$s' has been %2\$s.", 'jetpack-boost' ), $module_slug, $status_label ) + ); + } else { + /* translators: The %1$s refers to the module slug, %2$s refers to the module state (either activated or deactivated)*/ + \WP_CLI::warning( + sprintf( __( "'%1\$s' was already %2\$s.", 'jetpack-boost' ), $module_slug, $status_label ) + ); + } } /** diff --git a/projects/plugins/boost/changelog/add-boost-full-stack-e2e-infrastructure b/projects/plugins/boost/changelog/add-boost-full-stack-e2e-infrastructure new file mode 100644 index 000000000000..f82d2506bd37 --- /dev/null +++ b/projects/plugins/boost/changelog/add-boost-full-stack-e2e-infrastructure @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed + +Fix WP-CLI module activation not triggering side-effect hooks (e.g., Cloud CSS regeneration). Add full-stack E2E test infrastructure for the Boost Cloud pipeline. diff --git a/projects/plugins/boost/tests/e2e/.gitignore b/projects/plugins/boost/tests/e2e/.gitignore index b72d349f38d5..f5465a2fc9f0 100644 --- a/projects/plugins/boost/tests/e2e/.gitignore +++ b/projects/plugins/boost/tests/e2e/.gitignore @@ -6,4 +6,5 @@ plan-data.txt e2e_tunnels.txt jetpack-private-options.txt /allure-results +/.state storage.json diff --git a/projects/plugins/boost/tests/e2e/lib/fixtures/full-stack-test.ts b/projects/plugins/boost/tests/e2e/lib/fixtures/full-stack-test.ts new file mode 100644 index 000000000000..c164baa7a9e3 --- /dev/null +++ b/projects/plugins/boost/tests/e2e/lib/fixtures/full-stack-test.ts @@ -0,0 +1,50 @@ +/** + * Playwright fixture for full-stack Boost E2E tests. + * + * Extends base-test from e2e-commons. The inherited beforeEach/afterEach hooks + * (WPCOM request counting) target the e2e container — harmless overhead when + * that container is running, which it typically is during local dev. + * + * Provides: + * - fullStackUtils (worker-scoped): Docker, Redis, WP-CLI operations against dev container + * - jetpackBoostPage (test-scoped): Boost admin page object + */ + +import { test as baseTest, expect } from '_jetpack-e2e-commons/fixtures/base-test'; +import JetpackBoostPage from '../pages/jetpack-boost-page'; +import { FullStackUtils } from '../utils/full-stack-utils'; + +const test = baseTest.extend< + { jetpackBoostPage: JetpackBoostPage }, + { fullStackUtils: FullStackUtils } +>( { + jetpackBoostPage: async ( { page }, use ) => { + await use( new JetpackBoostPage( page ) ); + }, + // Worker-scoped: one instance shared across all tests in a worker. + // Full-stack specs must use test.describe.serial because the shared Docker + // environment (dev WordPress, Redis, boost-cloud) cannot handle concurrent tests. + fullStackUtils: [ + async ( {}, use ) => { + await use( new FullStackUtils() ); + }, + { scope: 'worker' }, + ], +} ); + +// Capture Docker logs on test failure for post-mortem debugging in the Playwright HTML report. +test.afterEach( async ( { fullStackUtils }, testInfo ) => { + if ( testInfo.status !== testInfo.expectedStatus ) { + try { + const logs = await fullStackUtils.captureDockerLogs(); + await testInfo.attach( 'docker-logs', { + body: logs, + contentType: 'text/plain', + } ); + } catch { + // Don't mask the real test failure + } + } +} ); + +export { test, expect }; diff --git a/projects/plugins/boost/tests/e2e/lib/full-stack-global-setup.ts b/projects/plugins/boost/tests/e2e/lib/full-stack-global-setup.ts new file mode 100644 index 000000000000..b7162f94adbc --- /dev/null +++ b/projects/plugins/boost/tests/e2e/lib/full-stack-global-setup.ts @@ -0,0 +1,193 @@ +/** + * Full-stack E2E test setup project. + * + * Performs health-check gates to verify the full-stack environment is ready, + * then authenticates against the dev WordPress and saves storage state. + * + * This file is referenced as a Playwright setup project in playwright.config.ts + * and runs before any full-stack test specs. + */ + +import { mkdirSync, existsSync } from 'fs'; +import { dirname, join } from 'path'; +import { fileURLToPath } from 'url'; +import { test as setup, expect } from '@playwright/test'; +import { + getDevDomain, + executeDevWpCommand, + execDocker, + flushRedis, +} from './utils/full-stack-utils'; + +// Keep in sync with storageState path in playwright.config.ts fullStackProjects. +const STORAGE_STATE_PATH = join( + dirname( fileURLToPath( import.meta.url ) ), + '..', + '.state', + 'full-stack-storage-state.json' +); + +setup( 'full-stack environment health check', async ( { request } ) => { + const boostCloudDir = process.env.BOOST_CLOUD_DIR; + // eslint-disable-next-line playwright/no-conditional-in-test + if ( ! boostCloudDir ) { + throw new Error( + 'BOOST_CLOUD_DIR environment variable is required. ' + + 'Set it to the path of your boost-cloud repository.' + ); + } + const devDomain = getDevDomain(); + + // Gate 1: BOOST_CLOUD_DIR is valid + await setup.step( 'BOOST_CLOUD_DIR has docker-compose.yml', () => { + expect( + existsSync( join( boostCloudDir, 'docker-compose.yml' ) ), + `docker-compose.yml not found at ${ boostCloudDir }` + ).toBe( true ); + } ); + + // Gate 2: WordPress is alive + await setup.step( 'WordPress is reachable', async () => { + const response = await request.get( `http://${ devDomain }/`, { + maxRedirects: 0, + failOnStatusCode: false, + } ); + expect( + [ 200, 301, 302 ], + `WordPress at http://${ devDomain }/ returned ${ response.status() }` + ).toContain( response.status() ); + } ); + + // Gate 3: Shield API is healthy. + // Uses localhost:1982 (host-side port mapping) — not boost-shield:1982 (Docker-internal). + // BOOST_DEV_DEFAULTS.shield_url uses the Docker hostname for container-to-container routing. + await setup.step( 'Shield API is healthy', async () => { + let healthy = false; + for ( let attempt = 0; attempt < 6; attempt++ ) { + try { + const response = await fetch( 'http://localhost:1982/v2/health', { + signal: AbortSignal.timeout( 5_000 ), + } ); + const body = ( await response.json() ) as { status: string }; + // eslint-disable-next-line playwright/no-conditional-in-test + if ( body.status === 'ok' ) { + healthy = true; + break; + } + } catch { + // Shield may be starting up + } + await new Promise( resolve => setTimeout( resolve, 5_000 ) ); + } + expect( healthy, 'Shield health check at localhost:1982/v2/health did not return ok' ).toBe( + true + ); + } ); + + // Gate 4: Redis is reachable + await setup.step( 'Redis is reachable', async () => { + const output = await execDocker( [ + 'compose', + '-f', + `${ boostCloudDir }/docker-compose.yml`, + 'exec', + '-T', + 'redis', + 'redis-cli', + 'ping', + ] ); + expect( output, 'Redis did not respond with PONG' ).toContain( 'PONG' ); + } ); + + // Gate 5: Hydra can reach WordPress + await setup.step( 'Hydra can reach WordPress', async () => { + const output = await execDocker( [ + 'compose', + '-f', + `${ boostCloudDir }/docker-compose.yml`, + 'exec', + '-T', + 'boost-hydra-css', + 'curl', + '-s', + '-o', + '/dev/null', + '-w', + '%{http_code}', + `http://${ devDomain }/`, + ] ); + // Extract 3-digit HTTP status code — execDocker concatenates stdout+stderr, + // so Docker Compose warnings may surround curl's status code output. + const match = output.match( /\b[1-5]\d{2}\b/ ); + expect( + match, + `No HTTP status code found in Docker output: ${ output.slice( 0, 200 ) }` + ).not.toBeNull(); + const httpCode = parseInt( match![ 0 ], 10 ); + expect( + [ 200, 301, 302 ], + `Hydra got HTTP ${ httpCode } reaching http://${ devDomain }/` + ).toContain( httpCode ); + } ); + + // Gate 6: boost-developer plugin is active + await setup.step( 'boost-developer plugin is active', async () => { + const output = await executeDevWpCommand( 'plugin list --status=active --format=json' ); + const plugins = JSON.parse( output.trim() ) as Array< { name: string } >; + const names = plugins.map( p => p.name ); + expect( names, 'boost-developer plugin is not active' ).toContain( 'boost-developer' ); + } ); + + // Gate 7: No debug-critical-css-providers.php mu-plugin + await setup.step( 'debug-critical-css-providers mu-plugin is not present', async () => { + const output = await executeDevWpCommand( [ + 'eval', + "echo file_exists(WPMU_PLUGIN_DIR . '/debug-critical-css-providers.php') ? 'EXISTS' : 'NOT_FOUND';", + ] ); + expect( + output.trim(), + 'debug-critical-css-providers.php is present in mu-plugins. ' + + 'This file hardcodes external CSS provider URLs (wincityvoices.org), ' + + 'causing Hydra to generate CSS for the wrong site. ' + + 'Remove it: rm tools/docker/mu-plugins/debug-critical-css-providers.php' + ).toContain( 'NOT_FOUND' ); + } ); + + // Gate 8: Flush Redis to clear stale BullMQ jobs from interrupted prior runs + await setup.step( 'Flush Redis', async () => { + await flushRedis( boostCloudDir ); + } ); + + // Gate 9: Authenticate against dev WordPress and save storage state + await setup.step( 'Authenticate against dev WordPress', async () => { + const loginResponse = await request.post( `http://${ devDomain }/wp-login.php`, { + form: { + log: 'jp_docker_acct', + pwd: 'jp_docker_pass', + rememberme: 'forever', + 'wp-submit': 'Log In', + redirect_to: `http://${ devDomain }/wp-admin/`, + }, + } ); + expect( + loginResponse.ok() || loginResponse.status() === 302, + `Login failed with status ${ loginResponse.status() }` + ).toBe( true ); + + // Verify authentication by requesting an admin page — a 200 with the login + // form would be a false positive from the POST above. + const adminResponse = await request.get( `http://${ devDomain }/wp-admin/profile.php` ); + expect( + adminResponse.url(), + 'Authentication failed: admin request was redirected to the login page' + ).not.toContain( 'wp-login.php' ); + + // Save storage state for the full-stack test project + const stateDir = dirname( STORAGE_STATE_PATH ); + // eslint-disable-next-line playwright/no-conditional-in-test + if ( ! existsSync( stateDir ) ) { + mkdirSync( stateDir, { recursive: true } ); + } + await request.storageState( { path: STORAGE_STATE_PATH } ); + } ); +} ); diff --git a/projects/plugins/boost/tests/e2e/lib/utils/full-stack-utils.ts b/projects/plugins/boost/tests/e2e/lib/utils/full-stack-utils.ts new file mode 100644 index 000000000000..d8dc70e10edd --- /dev/null +++ b/projects/plugins/boost/tests/e2e/lib/utils/full-stack-utils.ts @@ -0,0 +1,332 @@ +/** + * Full-stack test utilities for Jetpack Boost. + * + * Provides Docker-aware operations, WP-CLI polling, and Redis management + * for full-stack E2E tests that exercise the complete cloud pipeline + * (WordPress → Shield → Redis → Hydra → callback). + * + * Targets the dev WordPress container (not e2e) because boost-cloud services + * run on the jetpack_dev_default Docker network. + */ + +import { execFile } from 'child_process'; +import { readFileSync, existsSync } from 'fs'; +import { join } from 'path'; +import { promisify } from 'util'; +import { executeCommand } from '_jetpack-e2e-commons/utils/cli'; + +const execFileAsync = promisify( execFile ); + +/** + * All 19 keys from boost-developer.php:50-69. + * boost_dev_get() sets any missing key to `false` (not the default) when the option exists, + * so ALL keys must be present. Missing `local_shield` = false breaks Shield routing. + */ +export const BOOST_DEV_DEFAULTS: Record< string, string | number | boolean > = { + cornerstone_pages_plan: 'default', + css_mode: 'default', + local_shield: 'on', + shield_url: 'http://boost-shield:1982', + client_id: 'boost-plugin', + client_secret: 'plugin-secret', + skip_check: false, + force_error: 'none', + docker_urls: false, + mobile_score: 80, + desktop_score: 90, + fake_speed_score: 'off', + datasync_debug: 'off', + site_url: '', + custom_site_url: '', + wpcom_token: '', + proxy_host: '', + proxy_port: '', + lcp_element: '', +}; + +/** + * Read DEV_DOMAIN from the boost-cloud .env file. + * + * @return {string} The dev domain, or 'jetpack-boost.test' as fallback. + */ +export function getDevDomain(): string { + const dir = process.env.BOOST_CLOUD_DIR; + if ( ! dir ) { + return 'jetpack-boost.test'; + } + try { + const envContent = readFileSync( join( dir, '.env' ), 'utf8' ); + const match = envContent.match( /^DEV_DOMAIN=(.+)$/m ); + return match?.[ 1 ]?.trim().replace( /^["']|["']$/g, '' ) ?? 'jetpack-boost.test'; + } catch { + return 'jetpack-boost.test'; + } +} + +/** + * Execute a WP-CLI command against the dev WordPress container. + * + * Uses `pnpm jetpack docker wp --` (no --type e2e --name t1) to target the dev container. + * Passes --url to ensure home_url() returns the correct domain instead of http://localhost + * (wp-config.php:99 sets WP_HOME='http://localhost' when HTTP_HOST is empty in Docker). + * + * **Use array form for commands with spaces in arguments** (JSON values, `wp eval` code, etc.). + * The string form splits on whitespace, matching the e2e-commons `executeCommand` convention, + * but will break quoted/space-containing args. + * + * @param command - WP-CLI command string or argument array. + * @return {Promise} Command stdout. + */ +export async function executeDevWpCommand( command: string | string[] ): Promise< string > { + const devDomain = getDevDomain(); + const base = [ 'pnpm', 'jetpack', 'docker', 'wp', '--', `--url=http://${ devDomain }` ]; + if ( Array.isArray( command ) ) { + return executeCommand( [ ...base, ...command ] ); + } + return executeCommand( [ ...base, ...command.trim().split( /\s+/ ) ] ); +} + +/** + * Execute a Jetpack Boost CLI command against the dev WordPress container. + * + * @param command - Boost CLI subcommand string or argument array. + * @return {Promise} Command stdout. + */ +export async function executeDevJetpackBoostCommand( + command: string | string[] +): Promise< string > { + if ( Array.isArray( command ) ) { + return executeDevWpCommand( [ 'jetpack-boost', ...command ] ); + } + return executeDevWpCommand( `jetpack-boost ${ command }` ); +} + +/** + * Execute a Docker CLI command via child_process.execFile. + * Uses execFile directly because the e2e-commons executeCommand allowlist + * (wp, pnpm, sh) does not include docker. execFile is safe against + * shell injection (no shell expansion). + * + * @param args - Arguments passed to the docker CLI. + * @return {Promise} Combined stdout and stderr. + */ +export async function execDocker( args: string[] ): Promise< string > { + try { + const { stdout, stderr } = await execFileAsync( 'docker', args, { + timeout: 30_000, + } ); + return stdout + stderr; + } catch ( error ) { + const msg = String( error ); + if ( msg.includes( 'ENOENT' ) ) { + throw new Error( 'Docker not found. Is Docker installed and in your PATH?' ); + } + if ( msg.includes( 'Cannot connect' ) || msg.includes( 'connect ECONNREFUSED' ) ) { + throw new Error( + 'Docker daemon is not running. Start Docker Desktop or the Docker service.' + ); + } + throw error; + } +} + +/** + * Flush all Redis data in the boost-cloud Docker stack. + * Clears BullMQ job queues and dedup locks. + * + * @param boostCloudDir - Path to the boost-cloud repository. + */ +export async function flushRedis( boostCloudDir: string ): Promise< void > { + await execDocker( [ + 'compose', + '-f', + `${ boostCloudDir }/docker-compose.yml`, + 'exec', + '-T', + 'redis', + 'redis-cli', + 'FLUSHALL', + ] ); +} + +/** + * Full-stack test utilities class. + * Worker-scoped in the Playwright fixture (one instance per worker). + */ +export class FullStackUtils { + private boostCloudDir: string; + + constructor() { + const dir = process.env.BOOST_CLOUD_DIR; + if ( ! dir ) { + throw new Error( + 'BOOST_CLOUD_DIR environment variable is required. ' + + 'Set it to the path of your boost-cloud repository.' + ); + } + if ( ! existsSync( join( dir, 'docker-compose.yml' ) ) ) { + throw new Error( + `BOOST_CLOUD_DIR is set to '${ dir }' but docker-compose.yml was not found there.` + ); + } + this.boostCloudDir = dir; + } + + /** + * Reset the full-stack environment to a clean state. + * + * wp jetpack-boost reset is a FULL UNINSTALL: deletes all jetpack_boost_% options via SQL, + * clears CSS storage posts, and removes transients. boost_dev survives (no jetpack_boost_ prefix). + */ + async resetFullStackEnvironment(): Promise< void > { + // Full reset (uninstall + deactivate) + await executeDevWpCommand( 'jetpack-boost reset' ); + + // Ensure both plugins are active + await executeDevWpCommand( 'plugin activate jetpack-boost boost-developer' ); + + // Deactivate mock plugins that may be left from prior e2e test runs + try { + await executeDevWpCommand( + 'plugin deactivate e2e-mock-boost-connection e2e-mock-speed-score-api e2e-mock-premium-features' + ); + } catch ( error ) { + // Expected when mock plugins are not installed; log other errors for debugging. + console.debug( 'Mock plugin deactivation (non-fatal):', String( error ) ); + } + + // Write all 19 boost_dev keys with correct defaults + await executeDevWpCommand( [ + 'option', + 'update', + 'boost_dev', + JSON.stringify( BOOST_DEV_DEFAULTS ), + '--format=json', + ] ); + + // Clear Redis to prevent BullMQ dedup hangs from stale jobs + await this.flushRedis(); + } + + /** + * Flush all Redis data. Clears BullMQ job queues and dedup locks. + */ + async flushRedis(): Promise< void > { + await flushRedis( this.boostCloudDir ); + } + + /** + * Poll WP-CLI for Critical CSS generation completion. + * + * Option: jetpack_boost_ds_critical_css_state (wp-js-data-sync.php:17 + class-critical-css.php:92) + * States: not_generated → pending → generated | error (class-critical-css-state.php:9-14) + * + * @param timeout - Maximum wait time in milliseconds. + */ + async waitForCssGeneration( timeout = 120_000 ): Promise< void > { + const pollInterval = 5_000; + const start = Date.now(); + let lastState = 'unknown'; + + while ( Date.now() - start < timeout ) { + try { + const output = await executeDevWpCommand( + 'option get jetpack_boost_ds_critical_css_state --format=json' + ); + const state = JSON.parse( output.trim() ); + lastState = JSON.stringify( state ); + + if ( state?.status === 'generated' ) { + return; + } + + if ( state?.status === 'error' ) { + const errorMsg = state?.status_error ?? 'Unknown error'; + throw new Error( `CSS generation failed: ${ errorMsg }` ); + } + } catch ( error ) { + if ( error instanceof Error && error.message.startsWith( 'CSS generation failed' ) ) { + throw error; + } + // Option may not exist yet, keep polling + } + + await new Promise( resolve => setTimeout( resolve, pollInterval ) ); + } + + // Timeout — capture diagnostics + let logs = ''; + try { + logs = await this.captureDockerLogs(); + } catch { + // Best effort + } + + throw new Error( + `CSS generation timed out after ${ timeout / 1000 }s. ` + + `Last state: ${ lastState }. ` + + `Docker logs:\n${ logs.slice( 0, 2000 ) }` + ); + } + + /** + * Update a single key in the boost_dev WordPress option. + * + * @param key - Option key within the boost_dev object. + * @param value - New value for the key. + */ + async setBoostDevOption( key: string, value: unknown ): Promise< void > { + const output = await executeDevWpCommand( 'option get boost_dev --format=json' ); + const current = JSON.parse( output.trim() ); + current[ key ] = value; + await executeDevWpCommand( [ + 'option', + 'update', + 'boost_dev', + JSON.stringify( current ), + '--format=json', + ] ); + } + + /** + * Capture recent Docker logs from Shield and Hydra CSS services. + * Wrapped in error handling to avoid masking real test failures. + * + * @return {Promise} Combined stdout and stderr from the log tail. + */ + async captureDockerLogs(): Promise< string > { + return execDocker( [ + 'compose', + '-f', + `${ this.boostCloudDir }/docker-compose.yml`, + 'logs', + '--tail=200', + 'boost-shield', + 'boost-hydra-css', + ] ); + } + + /** + * Activate one or more Boost modules on the dev WordPress container. + * + * @param modules - Module slug(s) to activate. + */ + async activateModule( modules: string | string[] ): Promise< void > { + const moduleArray = Array.isArray( modules ) ? modules : [ modules ]; + for ( const mod of moduleArray ) { + await executeDevJetpackBoostCommand( `module activate ${ mod }` ); + } + } + + /** + * Deactivate one or more Boost modules on the dev WordPress container. + * + * @param modules - Module slug(s) to deactivate. + */ + async deactivateModule( modules: string | string[] ): Promise< void > { + const moduleArray = Array.isArray( modules ) ? modules : [ modules ]; + for ( const mod of moduleArray ) { + await executeDevJetpackBoostCommand( `module deactivate ${ mod }` ); + } + } +} diff --git a/projects/plugins/boost/tests/e2e/playwright.config.ts b/projects/plugins/boost/tests/e2e/playwright.config.ts index 6f3d7983c8c1..34e325d246f9 100644 --- a/projects/plugins/boost/tests/e2e/playwright.config.ts +++ b/projects/plugins/boost/tests/e2e/playwright.config.ts @@ -1,5 +1,62 @@ +import { readFileSync } from 'fs'; +import { join } from 'path'; import baseConfig, { setupProjects } from '_jetpack-e2e-commons/playwright.config.default'; +/** + * Read DEV_DOMAIN from the boost-cloud .env file for full-stack test baseURL. + * Falls back to 'jetpack-boost.test' if BOOST_CLOUD_DIR is not set or .env is unreadable. + * + * Intentionally duplicated from lib/utils/full-stack-utils.ts to avoid pulling + * test utility imports into config evaluation. + * + * @return {string} The dev domain, or 'jetpack-boost.test' as fallback. + */ +function getDevDomain(): string { + const dir = process.env.BOOST_CLOUD_DIR; + if ( ! dir ) { + return 'jetpack-boost.test'; + } + try { + const env = readFileSync( join( dir, '.env' ), 'utf8' ); + return ( + env + .match( /^DEV_DOMAIN=(.+)$/m )?.[ 1 ] + ?.trim() + .replace( /^["']|["']$/g, '' ) ?? 'jetpack-boost.test' + ); + } catch { + return 'jetpack-boost.test'; + } +} + +// Full-stack projects are only registered when BOOST_CLOUD_DIR is set. +// Playwright treats skipped setup projects as successful, so dependent projects +// would still run and fail — conditional registration is the only safe approach. +const fullStackProjects = process.env.BOOST_CLOUD_DIR + ? [ + { + name: 'full-stack setup', + testDir: './lib', + testMatch: 'full-stack-global-setup.ts', + dependencies: [ 'environment check' ], + storageState: undefined as undefined, + }, + { + name: 'full-stack', + testMatch: '**/specs/full-stack/**', + dependencies: [ 'full-stack setup' ], + // Force single worker — full-stack tests mutate shared state + // (dev WordPress, boost-cloud Redis) and cannot run in parallel. + workers: 1, + use: { + baseURL: `http://${ getDevDomain() }`, + // Keep in sync with STORAGE_STATE_PATH in lib/full-stack-global-setup.ts. + storageState: '.state/full-stack-storage-state.json', + }, + }, + ] + : []; + export default { ...baseConfig, use: { @@ -11,7 +68,9 @@ export default { { name: 'jetpack boost e2e', testMatch: '**/specs/**', + testIgnore: '**/specs/full-stack/**', dependencies: [ 'global authentication' ], }, + ...fullStackProjects, ], }; diff --git a/projects/plugins/boost/tests/e2e/specs/full-stack/critical-css.test.ts b/projects/plugins/boost/tests/e2e/specs/full-stack/critical-css.test.ts new file mode 100644 index 000000000000..581af121ec67 --- /dev/null +++ b/projects/plugins/boost/tests/e2e/specs/full-stack/critical-css.test.ts @@ -0,0 +1,55 @@ +/** + * Full-stack Critical CSS generation test. + * + * Exercises the complete cloud pipeline: + * WordPress → Shield → Redis → Hydra (Chromium) → callback → WordPress + * + * Requires: Jetpack dev Docker + boost-cloud Docker + boost-developer plugin + */ + +import { test, expect } from '../../lib/fixtures/full-stack-test'; + +test.describe.serial( 'Full-stack Critical CSS generation', () => { + test.beforeAll( async ( { fullStackUtils } ) => { + await fullStackUtils.resetFullStackEnvironment(); + + // Set cloud CSS mode so boost-developer enables the cloud-critical-css feature + // (Actions.php adds jetpack_boost_has_feature_cloud-critical-css filter when css_mode === 'cloud') + await fullStackUtils.setBoostDevOption( 'css_mode', 'cloud' ); + + // Activating cloud_css triggers Cloud_CSS::activate() → Regenerate::start() + // via the jetpack_boost_module_status_updated action in the CLI. + // This sends a CSS generation request to Shield immediately. + await fullStackUtils.activateModule( 'cloud_css' ); + } ); + + test( 'generates Critical CSS via Shield and Hydra', async ( { + fullStackUtils, + jetpackBoostPage, + page, + } ) => { + // Poll WP-CLI for CSS generation completion. + // Option: jetpack_boost_ds_critical_css_state (wp-js-data-sync.php:17 + class-critical-css.php:92) + // States: not_generated → pending → generated | error (class-critical-css-state.php:9-14) + await fullStackUtils.waitForCssGeneration( 120_000 ); + + // Verify the admin UI shows generation metadata. + // Test ID: data-testid="critical-css-meta" (status/status.tsx:59) + await jetpackBoostPage.visit(); + await expect( page.getByTestId( 'critical-css-meta' ) ).toBeVisible( { + timeout: 30_000, + } ); + } ); + + test( 'Critical CSS is present on the frontend', async ( { page } ) => { + await page.goto( '/' ); + + // Selector: