From 7419b9427f48deeb2d6686731a283cd77033aecf Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 15 May 2026 13:25:14 -0700 Subject: [PATCH 1/2] Performance Lab: Show filesystem credentials modal on plugin install When `FS_METHOD` is not `direct` and FTP/SSH credentials have not been stored, `Plugin_Upgrader::install()` returns `false` without raising a `WP_Error`, so the `:activate` REST endpoint falls through with a generic `plugin_not_found` 404 and the click on Activate appears to do nothing. The standard `Plugins > Add Plugin` flow handles the same case by displaying the "Connection Information" modal. Print that modal on the Performance Features screen, expose `filesystemCredentialsRequired` to the activation JS, and route the install through `wp.updates.installPlugin()` when credentials are required so the legacy AJAX install path supplies them. The install helper resolves with `'installed' | 'canceled'` so cancel is a normal control-flow outcome rather than an exception. After install, the existing REST `:activate` call performs the activation step (which no longer needs filesystem access). --- .../performance-lab/includes/admin/load.php | 69 ++++++++++++++ .../includes/admin/plugin-activate-ajax.js | 93 +++++++++++++++++++ 2 files changed, 162 insertions(+) diff --git a/plugins/performance-lab/includes/admin/load.php b/plugins/performance-lab/includes/admin/load.php index 8d5de33922..00fd9c21e8 100644 --- a/plugins/performance-lab/includes/admin/load.php +++ b/plugins/performance-lab/includes/admin/load.php @@ -53,6 +53,31 @@ function perflab_load_features_page(): void { // Handle style for settings page. add_action( 'admin_head', 'perflab_print_features_page_style' ); + + // Print the filesystem credentials modal in the footer so the activation + // flow can surface it when FS_METHOD is not 'direct'. + add_action( 'admin_footer', 'perflab_print_filesystem_credentials_modal' ); +} + +/** + * Prints the filesystem credentials modal on the Performance Features screen. + * + * When `FS_METHOD` is anything other than 'direct' and FTP/SSH credentials + * have not been stored, `Plugin_Upgrader::install()` returns false without + * raising a WP_Error, so the REST activation endpoint falls through with a + * generic `plugin_not_found` response and the click appears to do nothing. + * Mirroring the markup that Plugins > Add Plugin already provides lets the + * activation JS fall back to `wp.updates.installPlugin()` to surface the + * standard "Connection Information" dialog. + * + * @since n.e.x.t + */ +function perflab_print_filesystem_credentials_modal(): void { + require_once ABSPATH . 'wp-admin/includes/file.php'; + require_once ABSPATH . 'wp-admin/includes/template.php'; + + wp_print_request_filesystem_credentials_modal(); + wp_print_admin_notice_templates(); } /** @@ -401,6 +426,10 @@ function perflab_enqueue_features_page_scripts(): void { wp_enqueue_style( 'thickbox' ); wp_enqueue_script( 'plugin-install' ); + // Needed for the filesystem credentials modal fallback when FS_METHOD is + // not 'direct'. See perflab_print_filesystem_credentials_modal(). + wp_enqueue_script( 'updates' ); + // Enqueue plugin activate AJAX script and localize script data. wp_enqueue_script( 'perflab-plugin-activate-ajax', @@ -409,6 +438,46 @@ function perflab_enqueue_features_page_scripts(): void { PERFLAB_VERSION, true ); + + wp_add_inline_script( + 'perflab-plugin-activate-ajax', + sprintf( + 'window.perflabPluginActivate = %s;', + wp_json_encode( + array( + 'filesystemCredentialsRequired' => perflab_filesystem_credentials_required(), + ) + ) + ), + 'before' + ); +} + +/** + * Determines whether the current request needs filesystem credentials in + * order to install a plugin. + * + * Returns true when `get_filesystem_method()` is not 'direct' and FTP/SSH + * credentials have not yet been stored via {@see request_filesystem_credentials()}. + * The activation JS uses this flag to route through `wp.updates.installPlugin()` + * for the standard credentials prompt. + * + * @since n.e.x.t + * + * @return bool Whether filesystem credentials are required. + */ +function perflab_filesystem_credentials_required(): bool { + require_once ABSPATH . 'wp-admin/includes/file.php'; + + if ( 'direct' === get_filesystem_method() ) { + return false; + } + + ob_start(); + $stored = request_filesystem_credentials( self_admin_url() ); + ob_end_clean(); + + return false === $stored; } /** diff --git a/plugins/performance-lab/includes/admin/plugin-activate-ajax.js b/plugins/performance-lab/includes/admin/plugin-activate-ajax.js index 094665b53e..c166415b4f 100644 --- a/plugins/performance-lab/includes/admin/plugin-activate-ajax.js +++ b/plugins/performance-lab/includes/admin/plugin-activate-ajax.js @@ -7,11 +7,89 @@ const { i18n, a11y, apiFetch } = wp; const { __ } = i18n; + // Whether the current request needs FTP/SSH credentials before the + // plugin can be installed. Set from PHP via wp_add_inline_script(). + const filesystemCredentialsRequired = Boolean( + // @ts-ignore + window.perflabPluginActivate?.filesystemCredentialsRequired + ); + // Queue to hold pending activation requests. /** @type {{target: HTMLElement, pluginSlug: string}[]} */ const activationQueue = []; let isProcessingActivation = false; + /** + * Installs a plugin via wp.updates.installPlugin() so the standard + * "Connection Information" modal handles the FTP/SSH credentials flow. + * + * Resolves with a status describing the outcome: + * - 'installed': the plugin was written to disk. + * - 'canceled': the user closed the credentials modal. + * Rejects only on a genuine install failure (e.g. download error or + * wp.updates not being available on the page). + * + * @param {string} slug Plugin slug. + * @return {Promise<'installed' | 'canceled'>} Outcome of the install. + */ + function installPluginViaWpUpdates( slug ) { + return new Promise( ( resolve, reject ) => { + // @ts-ignore + const updates = window.wp?.updates; + // @ts-ignore + const $ = window.jQuery; + if ( ! updates?.installPlugin || ! $ ) { + reject( + new Error( 'wp.updates.installPlugin is not available.' ) + ); + return; + } + + const cleanup = () => { + $( document ).off( 'wp-plugin-install-success', onSuccess ); + $( document ).off( 'wp-plugin-install-error', onError ); + $( document ).off( 'credential-modal-cancel', onCancel ); + }; + + /** + * @param {unknown} _event + * @param {{slug: string}} response + */ + const onSuccess = ( _event, response ) => { + if ( response.slug !== slug ) { + return; + } + cleanup(); + resolve( 'installed' ); + }; + + /** + * @param {unknown} _event + * @param {{slug: string, errorMessage?: string}} response + */ + const onError = ( _event, response ) => { + if ( response.slug !== slug ) { + return; + } + cleanup(); + reject( + new Error( response.errorMessage || 'Install failed.' ) + ); + }; + + const onCancel = () => { + cleanup(); + resolve( 'canceled' ); + }; + + $( document ).on( 'wp-plugin-install-success', onSuccess ); + $( document ).on( 'wp-plugin-install-error', onError ); + $( document ).on( 'credential-modal-cancel', onCancel ); + + updates.installPlugin( { slug } ); + } ); + } + /** * Enqueues plugin activation requests and starts processing if not already in progress. * @@ -63,6 +141,21 @@ a11y.speak( __( 'Activating…', 'performance-lab' ) ); try { + // When the filesystem method is not 'direct' and credentials + // have not been stored, the REST endpoint silently fails to + // install the plugin. Route the install through wp.updates so + // the standard "Connection Information" modal handles credential + // entry, then let the REST endpoint perform the activation step + // (which no longer needs filesystem access). + if ( filesystemCredentialsRequired ) { + const outcome = await installPluginViaWpUpdates( pluginSlug ); + if ( 'canceled' === outcome ) { + target.classList.remove( 'updating-message' ); + target.textContent = __( 'Activate', 'performance-lab' ); + return; + } + } + // Activate the plugin/feature via the REST API. await apiFetch( { path: `/performance-lab/v1/features/${ pluginSlug }:activate`, From 237f7f9174051f2fa3584055c8620c82fc690f11 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 15 May 2026 18:48:07 -0700 Subject: [PATCH 2/2] Performance Lab: Add tests for filesystem credentials fallback Cover the code paths added for the FS_METHOD-not-direct install flow: - perflab_load_features_page() registers the admin_footer modal printer. - perflab_enqueue_features_page_scripts() enqueues `updates` and prints the window.perflabPluginActivate inline data. - perflab_filesystem_credentials_required() returns false for the `direct` method, true when credentials are missing, and false when credentials are available (request_filesystem_credentials filter). - perflab_print_filesystem_credentials_modal() prints nothing for the `direct` method and the dialog markup when credentials are required. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../tests/includes/admin/test-load.php | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) diff --git a/plugins/performance-lab/tests/includes/admin/test-load.php b/plugins/performance-lab/tests/includes/admin/test-load.php index b8a558a57f..cafa085846 100644 --- a/plugins/performance-lab/tests/includes/admin/test-load.php +++ b/plugins/performance-lab/tests/includes/admin/test-load.php @@ -280,4 +280,133 @@ public function data_provider_to_test_perflab_sanitize_plugin_slug(): array { public function test_perflab_sanitize_plugin_slug( $slug, ?string $expected ): void { $this->assertSame( $expected, perflab_sanitize_plugin_slug( $slug ) ); } + + /** + * @covers ::perflab_load_features_page + */ + public function test_perflab_load_features_page_registers_filesystem_credentials_modal(): void { + remove_all_actions( 'admin_footer' ); + + perflab_load_features_page(); + + $this->assertSame( + 10, + has_action( 'admin_footer', 'perflab_print_filesystem_credentials_modal' ) + ); + $this->assertSame( + 10, + has_action( 'admin_enqueue_scripts', 'perflab_enqueue_features_page_scripts' ) + ); + } + + /** + * @covers ::perflab_enqueue_features_page_scripts + */ + public function test_perflab_enqueue_features_page_scripts_enqueues_updates_and_inline_data(): void { + // Ensure get_filesystem_method() is 'direct' so the inline data is deterministic. + add_filter( + 'filesystem_method', + static function (): string { + return 'direct'; + } + ); + + set_current_screen( 'options-general' ); + + perflab_enqueue_features_page_scripts(); + + $this->assertTrue( wp_script_is( 'updates', 'enqueued' ) ); + $this->assertTrue( wp_script_is( 'perflab-plugin-activate-ajax', 'enqueued' ) ); + + $inline = wp_scripts()->get_inline_script_data( 'perflab-plugin-activate-ajax', 'before' ); + $this->assertStringContainsString( 'window.perflabPluginActivate', $inline ); + $this->assertStringContainsString( '"filesystemCredentialsRequired":false', $inline ); + } + + /** + * @covers ::perflab_filesystem_credentials_required + */ + public function test_perflab_filesystem_credentials_required_is_false_for_direct_method(): void { + add_filter( + 'filesystem_method', + static function (): string { + return 'direct'; + } + ); + + $this->assertFalse( perflab_filesystem_credentials_required() ); + } + + /** + * @covers ::perflab_filesystem_credentials_required + */ + public function test_perflab_filesystem_credentials_required_is_true_when_credentials_missing(): void { + add_filter( + 'filesystem_method', + static function (): string { + return 'ftpext'; + } + ); + + // No FTP constants/credentials are available, so request_filesystem_credentials() returns false. + $this->assertTrue( perflab_filesystem_credentials_required() ); + } + + /** + * @covers ::perflab_filesystem_credentials_required + */ + public function test_perflab_filesystem_credentials_required_is_false_when_credentials_stored(): void { + add_filter( + 'filesystem_method', + static function (): string { + return 'ftpext'; + } + ); + + // Simulate stored/available credentials by short-circuiting request_filesystem_credentials(). + add_filter( + 'request_filesystem_credentials', + static function () { + return array( + 'hostname' => 'example.com', + 'username' => 'user', + 'password' => 'pass', + ); + } + ); + + $this->assertFalse( perflab_filesystem_credentials_required() ); + } + + /** + * @covers ::perflab_print_filesystem_credentials_modal + */ + public function test_perflab_print_filesystem_credentials_modal_prints_nothing_for_direct_method(): void { + add_filter( + 'filesystem_method', + static function (): string { + return 'direct'; + } + ); + + $output = get_echo( 'perflab_print_filesystem_credentials_modal' ); + + $this->assertStringNotContainsString( 'id="request-filesystem-credentials-dialog"', $output ); + } + + /** + * @covers ::perflab_print_filesystem_credentials_modal + */ + public function test_perflab_print_filesystem_credentials_modal_prints_dialog_when_credentials_required(): void { + add_filter( + 'filesystem_method', + static function (): string { + return 'ftpext'; + } + ); + + $output = get_echo( 'perflab_print_filesystem_credentials_modal' ); + + $this->assertStringContainsString( 'id="request-filesystem-credentials-dialog"', $output ); + } }