Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions projects/plugins/boost/.phan/baseline.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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'],
Expand Down
28 changes: 21 additions & 7 deletions projects/plugins/boost/app/lib/class-cli.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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();
Expand All @@ -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();
Expand All @@ -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 )
);
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions projects/plugins/boost/tests/e2e/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ plan-data.txt
e2e_tunnels.txt
jetpack-private-options.txt
/allure-results
/.state
storage.json
50 changes: 50 additions & 0 deletions projects/plugins/boost/tests/e2e/lib/fixtures/full-stack-test.ts
Original file line number Diff line number Diff line change
@@ -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 };
193 changes: 193 additions & 0 deletions projects/plugins/boost/tests/e2e/lib/full-stack-global-setup.ts
Original file line number Diff line number Diff line change
@@ -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 } );
} );
} );
Loading
Loading