From 8baf4f0dffd7e29cf954141912e17f9a5add33bf Mon Sep 17 00:00:00 2001 From: Liam Sarsfield <43409125+LiamSarsfield@users.noreply.github.com> Date: Mon, 6 Apr 2026 20:49:53 +0100 Subject: [PATCH 01/11] Add full-stack E2E test infrastructure for Boost Cloud pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Create Playwright fixtures, setup project, and smoke test that exercise the complete cloud pipeline (WordPress → Shield → Redis → Hydra → callback) against the local dev Docker environment. - Add FullStackUtils class with Docker-aware helpers (executeDevWpCommand, flushRedis, waitForCssGeneration, captureDockerLogs) - Create full-stack-test.ts fixture extending base-test from e2e-commons - Create full-stack-global-setup.ts with 9 health-check/auth gates - Add Critical CSS smoke test validating generation through Shield+Hydra - Add cloud_css to CLI module allowlist in class-cli.php - Fire jetpack_boost_module_status_updated action in CLI set_module_status() so CLI activation triggers the same hooks as the DataSync REST path --- projects/plugins/boost/app/lib/class-cli.php | 6 +- .../tests/e2e/lib/fixtures/full-stack-test.ts | 54 +++ .../tests/e2e/lib/full-stack-global-setup.ts | 216 +++++++++++ .../tests/e2e/lib/utils/full-stack-utils.ts | 335 ++++++++++++++++++ .../boost/tests/e2e/playwright.config.ts | 46 +++ .../e2e/specs/full-stack/critical-css.test.ts | 55 +++ 6 files changed, 711 insertions(+), 1 deletion(-) create mode 100644 projects/plugins/boost/tests/e2e/lib/fixtures/full-stack-test.ts create mode 100644 projects/plugins/boost/tests/e2e/lib/full-stack-global-setup.ts create mode 100644 projects/plugins/boost/tests/e2e/lib/utils/full-stack-utils.ts create mode 100644 projects/plugins/boost/tests/e2e/specs/full-stack/critical-css.test.ts diff --git a/projects/plugins/boost/app/lib/class-cli.php b/projects/plugins/boost/app/lib/class-cli.php index 494018c197ec..4d8f433a3ecf 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. @@ -124,6 +124,10 @@ private function set_module_status( $module_slug, $status ) { $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(). + do_action( 'jetpack_boost_module_status_updated', $module_slug, $status ); + if ( $module_slug === 'page_cache' && $status ) { $setup_result = Page_Cache_Setup::run_setup(); if ( is_wp_error( $setup_result ) ) { 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..ef67f2d9ee38 --- /dev/null +++ b/projects/plugins/boost/tests/e2e/lib/fixtures/full-stack-test.ts @@ -0,0 +1,54 @@ +/** + * 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, + activateBoostModuleDev, + deactivateBoostModuleDev, +} from '../utils/full-stack-utils'; + +const test = baseTest.extend< + { jetpackBoostPage: JetpackBoostPage }, + { fullStackUtils: FullStackUtils } +>( { + jetpackBoostPage: async ( { page }, use ) => { + page.on( 'pageerror', exception => { + console.error( `Page error: "${ exception }"` ); + } ); + await use( new JetpackBoostPage( page ) ); + }, + 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, activateBoostModuleDev, deactivateBoostModuleDev }; 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..2174dd800b6d --- /dev/null +++ b/projects/plugins/boost/tests/e2e/lib/full-stack-global-setup.ts @@ -0,0 +1,216 @@ +/** + * 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 { execFile } from 'child_process'; +import { readFileSync, mkdirSync, existsSync } from 'fs'; +import { join, dirname } from 'path'; +import { promisify } from 'util'; +import { test as setup, expect } from '@playwright/test'; +import { executeCommand } from '_jetpack-e2e-commons/utils/cli'; + +const execFileAsync = promisify( execFile ); + +/** + * Read DEV_DOMAIN from the boost-cloud .env file. + * + * @return {string} The dev domain, or 'jetpack-boost.test' as fallback. + */ +function getDevDomain(): string { + const dir = process.env.BOOST_CLOUD_DIR!; + try { + const envContent = readFileSync( join( dir, '.env' ), 'utf8' ); + const match = envContent.match( /^DEV_DOMAIN=(.+)$/m ); + return match?.[ 1 ] ?? 'jetpack-boost.test'; + } catch { + return 'jetpack-boost.test'; + } +} + +/** + * Execute a WP-CLI command against the dev WordPress container. + * + * @param command - WP-CLI command string or argument array. + * @return {Promise} Command stdout. + */ +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 Docker CLI command via child_process.execFile. + * + * @param args - Arguments passed to the docker CLI. + * @return {Promise} Combined stdout and stderr. + */ +async function execDocker( args: string[] ): Promise< string > { + const { stdout, stderr } = await execFileAsync( 'docker', args, { timeout: 30_000 } ); + return stdout + stderr; +} + +const STORAGE_STATE_PATH = join( + dirname( new URL( import.meta.url ).pathname ), + '..', + '.state', + 'full-stack-storage-state.json' +); + +setup( 'full-stack environment health check', async ( { request } ) => { + const boostCloudDir = process.env.BOOST_CLOUD_DIR!; + 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 + 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' ); + 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 }/`, + ] ); + const httpCode = parseInt( output.trim(), 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 execDocker( [ + 'compose', + '-f', + `${ boostCloudDir }/docker-compose.yml`, + 'exec', + '-T', + 'redis', + 'redis-cli', + 'FLUSHALL', + ] ); + } ); + + // 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 ); + + // 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 } ); + } ); +} ); + +export { 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..f5536ecd14d3 --- /dev/null +++ b/projects/plugins/boost/tests/e2e/lib/utils/full-stack-utils.ts @@ -0,0 +1,335 @@ +/** + * 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 ] ?? '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). + * + * Always uses array form to preserve JSON arguments containing spaces. + * + * @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 }` ); +} + +/** + * Activate one or more Boost modules on the dev WordPress container. + * + * @param modules - Module slug(s) to activate. + */ +export async function activateBoostModuleDev( 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. + */ +export async function deactivateBoostModuleDev( modules: string | string[] ): Promise< void > { + const moduleArray = Array.isArray( modules ) ? modules : [ modules ]; + for ( const mod of moduleArray ) { + await executeDevJetpackBoostCommand( `module deactivate ${ mod }` ); + } +} + +/** + * 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 { + // Ignore errors if plugins are not installed + } + + // 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. + * Uses -T flag to disable pseudo-TTY allocation (avoids TTY bug from cloud.sh). + */ + async flushRedis(): Promise< void > { + await this.execDocker( [ + 'compose', + '-f', + `${ this.boostCloudDir }/docker-compose.yml`, + 'exec', + '-T', + 'redis', + 'redis-cli', + 'FLUSHALL', + ] ); + } + + /** + * 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 ) }` + ); + } + + /** + * Check Shield API health. + * + * @return {Promise<{status: string}>} Health response object. + */ + async getShieldHealth(): Promise< { status: string } > { + const response = await fetch( 'http://localhost:1982/v2/health' ); + return ( await response.json() ) as { status: string }; + } + + /** + * 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 this.execDocker( [ + 'compose', + '-f', + `${ this.boostCloudDir }/docker-compose.yml`, + 'logs', + '--tail=200', + 'boost-shield', + 'boost-hydra-css', + ] ); + } + + /** + * Single point for all Docker command execution. + * Uses child_process.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. + */ + private async 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.' + ); + } + if ( msg.includes( 'is not running' ) ) { + throw new Error( + `boost-cloud Docker is not running. Start it with: cd ${ this.boostCloudDir } && docker compose up -d` + ); + } + throw error; + } + } +} diff --git a/projects/plugins/boost/tests/e2e/playwright.config.ts b/projects/plugins/boost/tests/e2e/playwright.config.ts index 6f3d7983c8c1..f06b297a04ed 100644 --- a/projects/plugins/boost/tests/e2e/playwright.config.ts +++ b/projects/plugins/boost/tests/e2e/playwright.config.ts @@ -1,5 +1,49 @@ +import { readFileSync } from 'fs'; 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. + * + * @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( `${ dir }/.env`, 'utf8' ); + return env.match( /^DEV_DOMAIN=(.+)$/m )?.[ 1 ] ?? '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' ], + use: { + baseURL: `http://${ getDevDomain() }`, + storageState: '.state/full-stack-storage-state.json', + }, + }, + ] + : []; + export default { ...baseConfig, use: { @@ -11,7 +55,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..81b154a74de0 --- /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, activateBoostModuleDev } 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 (added in Step 0b). + // This sends a CSS generation request to Shield immediately. + await activateBoostModuleDev( '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: