From 314fedb9dbd854b5a02015491f0fe9d66b0aa0e9 Mon Sep 17 00:00:00 2001 From: lightbulbman Date: Tue, 22 Sep 2026 19:17:58 +0100 Subject: [PATCH 01/25] feat: add version source and package installer seams for the version switcher --- src/php/Settings/Package_Installer.php | 27 +++ .../Settings/Upgrader_Package_Installer.php | 120 +++++++++++++ src/php/Settings/Version_Source.php | 110 ++++++++++++ .../Settings/WordPress_Org_Version_Source.php | 157 ++++++++++++++++++ 4 files changed, 414 insertions(+) create mode 100644 src/php/Settings/Package_Installer.php create mode 100644 src/php/Settings/Upgrader_Package_Installer.php create mode 100644 src/php/Settings/Version_Source.php create mode 100644 src/php/Settings/WordPress_Org_Version_Source.php diff --git a/src/php/Settings/Package_Installer.php b/src/php/Settings/Package_Installer.php new file mode 100644 index 000000000..197f33828 --- /dev/null +++ b/src/php/Settings/Package_Installer.php @@ -0,0 +1,27 @@ +install( + $package, + [ + 'overwrite_package' => true, + 'clear_update_cache' => true, + ] + ); + + if ( is_wp_error( $result ) ) { + return $result; + } + + if ( ! $result ) { + $messages = $this->extract_handler_messages( $update_handler, $upgrader ); + + return new WP_Error( + 'package_install_failed', + $messages + ? wp_trim_words( wp_strip_all_tags( $messages ), 40 ) + : __( 'The package could not be installed.', 'code-snippets' ) + ); + } + + // The upgrader reports nothing about what it wrote, so the caller falls + // back to the version it asked for. + return [ 'version' => '' ]; + } + + /** + * Extract error messages from an upgrade handler. + * + * @param WP_Upgrader_Skin|null $update_handler Update handler. + * @param Plugin_Upgrader|null $upgrader Plugin upgrader. + * + * @return string + * + * phpcs:disable WordPress.PHP.DevelopmentFunctions.error_log_print_r + */ + public function extract_handler_messages( ?WP_Upgrader_Skin $update_handler, ?Plugin_Upgrader $upgrader ): string { + $handler_messages = ''; + + if ( isset( $update_handler ) ) { + if ( method_exists( $update_handler, 'get_errors' ) ) { + $errs = $update_handler->get_errors(); + if ( $errs instanceof WP_Error && $errs->has_errors() ) { + $handler_messages .= implode( "\n", $errs->get_error_messages() ); + } + } + if ( method_exists( $update_handler, 'get_error_messages' ) ) { + $em = $update_handler->get_error_messages(); + if ( $em ) { + $handler_messages .= "\n" . $em; + } + } + if ( method_exists( $update_handler, 'get_upgrade_messages' ) ) { + $upgrade_msgs = $update_handler->get_upgrade_messages(); + if ( is_array( $upgrade_msgs ) ) { + $handler_messages .= "\n" . implode( "\n", $upgrade_msgs ); + } elseif ( $upgrade_msgs ) { + $handler_messages .= "\n" . $upgrade_msgs; + } + } + } + + if ( empty( $handler_messages ) && isset( $upgrader->result ) ) { + if ( is_wp_error( $upgrader->result ) ) { + $handler_messages = implode( "\n", $upgrader->result->get_error_messages() ); + } else { + $handler_messages = is_scalar( $upgrader->result ) + ? (string) $upgrader->result + : print_r( $upgrader->result, true ); + } + } + + return trim( $handler_messages ); + } +} diff --git a/src/php/Settings/Version_Source.php b/src/php/Settings/Version_Source.php new file mode 100644 index 000000000..62c0bb4a4 --- /dev/null +++ b/src/php/Settings/Version_Source.php @@ -0,0 +1,110 @@ +>, floor: string}|WP_Error + */ + public function fetch_catalogue(); + + /** + * Explain why the catalogue stops at the floor it reported. + * + * Only consulted when fetch_catalogue() returned a non-empty `floor`. + * + * @param string $floor Oldest installable version. + * + * @return string + */ + public function get_floor_notice( string $floor ): string; + + /** + * Obtain the package for a version. + * + * `package` is either a local file path or a download URL — the installer + * accepts both. Set `cleanup` when the switcher should delete the package + * once the install has finished, as it must for a temporary download. + * + * @param array $version_info Catalogue entry for the version. + * + * @return array{package: string, cleanup: bool}|WP_Error + */ + public function fetch_package( array $version_info ); + + /** + * Record a completed switch with whatever service tracks this site. + * + * Best-effort: an implementation must never turn a successful switch into a + * reported failure. + * + * @param string $version Version now installed. + * + * @return void + */ + public function report_installed_version( string $version ): void; +} diff --git a/src/php/Settings/WordPress_Org_Version_Source.php b/src/php/Settings/WordPress_Org_Version_Source.php new file mode 100644 index 000000000..f6351c1e9 --- /dev/null +++ b/src/php/Settings/WordPress_Org_Version_Source.php @@ -0,0 +1,157 @@ + __( 'The list of available versions could not be retrieved.', 'code-snippets' ), + 'action_url' => '', + 'action_label' => '', + ]; + } + + /** + * Describe where versions are fetched from. + * + * @return string + */ + public function get_refresh_description(): string { + return __( 'Check for the latest available plugin versions from WordPress.org.', 'code-snippets' ); + } + + /** + * Retrieve the versions available to install, ordered newest first. + * + * @return array{versions: array>, floor: string}|WP_Error + */ + public function fetch_catalogue() { + $response = wp_remote_get( self::API_ENDPOINT ); + + if ( is_wp_error( $response ) ) { + return new WP_Error( + 'version_source_request_error', + __( 'Failed to retrieve the list of available versions from WordPress.org.', 'code-snippets' ) + ); + } + + $data = json_decode( wp_remote_retrieve_body( $response ), true ); + + if ( ! is_array( $data ) || ! isset( $data['versions'] ) || ! is_array( $data['versions'] ) ) { + return new WP_Error( + 'version_source_response_error', + __( 'Did not receive a valid version list from WordPress.org.', 'code-snippets' ) + ); + } + + $versions = []; + + foreach ( $data['versions'] as $version => $download_url ) { + if ( 'trunk' !== $version ) { + $versions[] = [ + 'version' => (string) $version, + 'url' => (string) $download_url, + ]; + } + } + + usort( + $versions, + function ( array $a, array $b ) { + return version_compare( $b['version'], $a['version'] ); + } + ); + + return [ + 'versions' => $versions, + 'floor' => '', + ]; + } + + /** + * Explain why the catalogue stops at the floor it reported. WordPress.org + * lists every released build, so this source reports no floor. + * + * @param string $floor Oldest installable version. + * + * @return string + */ + public function get_floor_notice( string $floor ): string { + return ''; + } + + /** + * Obtain the package for a version. + * + * The catalogue entry already carries the download URL, which the installer + * fetches itself, so there is nothing to clean up afterwards. + * + * @param array $version_info Catalogue entry for the version. + * + * @return array{package: string, cleanup: bool}|WP_Error + */ + public function fetch_package( array $version_info ) { + if ( empty( $version_info['url'] ) ) { + return new WP_Error( + 'version_source_package_error', + __( 'No download is available for that version.', 'code-snippets' ) + ); + } + + return [ + 'package' => (string) $version_info['url'], + 'cleanup' => false, + ]; + } + + /** + * Record a completed switch. WordPress.org tracks nothing on this site's + * behalf, so there is nothing to report. + * + * @param string $version Version now installed. + * + * @return void + */ + public function report_installed_version( string $version ): void { + } +} From a59178d892af97cba5efe7dfa29db8fcea0c38e7 Mon Sep 17 00:00:00 2001 From: lightbulbman Date: Tue, 22 Sep 2026 19:18:14 +0100 Subject: [PATCH 02/25] refactor: resolve the version switcher source and installer through filters --- src/php/Settings/Version_Switch.php | 552 +++++++++++++++++----------- 1 file changed, 336 insertions(+), 216 deletions(-) diff --git a/src/php/Settings/Version_Switch.php b/src/php/Settings/Version_Switch.php index 786a14a4c..9c665b5d9 100644 --- a/src/php/Settings/Version_Switch.php +++ b/src/php/Settings/Version_Switch.php @@ -2,44 +2,54 @@ namespace Code_Snippets\Settings; -use Plugin_Upgrader; -use WP_Ajax_Upgrader_Skin; use WP_Error; -use WP_Upgrader_Skin; use function Code_Snippets\code_snippets; /** * Version switching functionality for the Code Snippets plugin. * + * This class owns the parts of switching that do not vary: the caches, the + * progress marker, the capability and nonce checks, and the rendering. Where + * versions come from, and how a package is written to disk, are supplied by a + * Version_Source and a Package_Installer, which an edition replaces through the + * `code_snippets_version_source` and `code_snippets_package_installer` filters. + * * @package Code_Snippets * @subpackage Settings */ class Version_Switch { /** - * Transient key where the available version data is cached. + * Transient key used to indicate when a version switch is currently taking place. */ - private const CACHE_KEY = 'code_snippets_available_versions'; + private const PROGRESS_KEY = 'code_snippets_version_switch_progress'; /** - * Transient key used to indicate when a version switch is currently taking place. + * Transient key holding the reason the last catalogue request failed. */ - private const PROGRESS_KEY = 'code_snippets_version_switch_progress'; + private const ERROR_KEY = 'code_snippets_version_switch_error'; /** * Duration of the version cache transient. */ private const VERSION_CACHE_DURATION = HOUR_IN_SECONDS; + /** + * Duration of the last-error transient. + */ + private const ERROR_CACHE_DURATION = 5 * MINUTE_IN_SECONDS; + /** * Duration of the 'in progress' transient. */ private const PROGRESS_TIMEOUT = 5 * MINUTE_IN_SECONDS; /** - * API endpoint for checking for available plugin versions. + * Source supplying the available versions. + * + * @var Version_Source|null */ - private const WORDPRESS_API_ENDPOINT = 'https://api.wordpress.org/plugins/info/1.2/?action=plugin_information&slug=code-snippets'; + private static ?Version_Source $source = null; /** * Initialise class. @@ -52,51 +62,152 @@ public static function init(): void { } /** - * Retrieve a list of plugin versions available for switching. + * Retrieve the source supplying available versions. * - * @return array + * @return Version_Source */ - public static function get_available_versions(): array { - $versions = get_transient( self::CACHE_KEY ); + public static function get_source(): Version_Source { + if ( null === self::$source ) { + /** + * Filters the source the version switcher lists versions from. + * + * @param Version_Source $source Default source. + */ + $source = apply_filters( 'code_snippets_version_source', new WordPress_Org_Version_Source() ); + + self::$source = $source instanceof Version_Source ? $source : new WordPress_Org_Version_Source(); + } - if ( false === $versions ) { - $response = wp_remote_get( self::WORDPRESS_API_ENDPOINT ); + return self::$source; + } - if ( is_wp_error( $response ) ) { - return []; - } + /** + * Retrieve the installer that writes a package to disk. + * + * @return Package_Installer + */ + public static function get_installer(): Package_Installer { + /** + * Filters the installer the version switcher writes packages with. + * + * @param Package_Installer $installer Default installer. + */ + $installer = apply_filters( 'code_snippets_package_installer', new Upgrader_Package_Installer() ); + + return $installer instanceof Package_Installer ? $installer : new Upgrader_Package_Installer(); + } - $body = wp_remote_retrieve_body( $response ); - $data = json_decode( $body, true ); + /** + * Discard the memoised source, so the next call resolves the filter again. + * + * @return void + */ + public static function reset_source(): void { + self::$source = null; + } - if ( ! $data || ! isset( $data['versions'] ) ) { - return []; - } + /** + * Determine whether versions can currently be listed. + * + * @return bool + */ + public static function is_available(): bool { + return self::get_source()->is_available(); + } - // Filter out 'trunk' and sort versions. - $versions = []; - foreach ( $data['versions'] as $version => $download_url ) { - if ( 'trunk' !== $version ) { - $versions[] = [ - 'version' => $version, - 'url' => $download_url, - ]; - } - } + /** + * Explain why the switcher is unavailable. + * + * @return array{message: string, action_url: string, action_label: string} + */ + public static function get_unavailable_notice(): array { + return self::get_source()->get_unavailable_notice(); + } - // Sort versions in descending order. - usort( - $versions, - function ( $a, $b ) { - return version_compare( $b['version'], $a['version'] ); - } - ); + /** + * Retrieve the cached catalogue, fetching it from the source when the cache + * is cold. + * + * A failed request is not cached: a transient outage must not blank the + * switcher for the full cache duration. + * + * @return array{versions: array>, floor: string} + */ + private static function get_catalogue(): array { + $source = self::get_source(); + $cached = get_transient( $source->get_cache_key() ); + + if ( is_array( $cached ) && isset( $cached['versions'] ) ) { + return $cached; + } + + $empty = [ + 'versions' => [], + 'floor' => '', + ]; - // Cache for configured duration. - set_transient( self::CACHE_KEY, $versions, self::VERSION_CACHE_DURATION ); + if ( ! $source->is_available() ) { + return $empty; } - return $versions; + $result = $source->fetch_catalogue(); + + if ( is_wp_error( $result ) ) { + set_transient( self::ERROR_KEY, $result->get_error_code(), self::ERROR_CACHE_DURATION ); + return $empty; + } + + $catalogue = [ + 'versions' => $result['versions'], + 'floor' => $result['floor'], + ]; + + delete_transient( self::ERROR_KEY ); + set_transient( $source->get_cache_key(), $catalogue, self::VERSION_CACHE_DURATION ); + + return $catalogue; + } + + /** + * Retrieve a list of plugin versions available for switching. + * + * @return array> + */ + public static function get_available_versions(): array { + return self::get_catalogue()['versions']; + } + + /** + * Retrieve the oldest installable version, if the source reported one. + * + * @return string + */ + public static function get_version_floor(): string { + return self::get_catalogue()['floor']; + } + + /** + * Retrieve the code of the last catalogue request failure. + * + * @return string + */ + public static function get_last_error_code(): string { + return (string) get_transient( self::ERROR_KEY ); + } + + /** + * Discard the cached catalogue and fetch a fresh one from the source. + * + * Used by the maintenance tools, where the point of clearing caches is to see + * what the source is serving right now rather than what it served an hour ago. + * + * @return array> The freshly fetched versions. + */ + public static function refresh_available_versions(): array { + delete_transient( self::get_source()->get_cache_key() ); + delete_transient( self::ERROR_KEY ); + + return self::get_available_versions(); } /** @@ -123,41 +234,58 @@ public static function is_version_switch_in_progress(): bool { * @return void */ public static function clear_version_caches(): void { - delete_transient( self::CACHE_KEY ); + delete_transient( self::get_source()->get_cache_key() ); delete_transient( self::PROGRESS_KEY ); + delete_transient( self::ERROR_KEY ); + } + + /** + * Locate a version in a catalogue. + * + * @param string $target_version Version to find. + * @param array> $available_versions Catalogue entries. + * + * @return array|null + */ + private static function find_version( string $target_version, array $available_versions ): ?array { + foreach ( $available_versions as $version_info ) { + if ( isset( $version_info['version'] ) && $version_info['version'] === $target_version ) { + return $version_info; + } + } + + return null; } /** * Validate that a target version is valid. * - * @param string $target_version Target version for switching. - * @param array $available_versions List of available versions. + * This is a usability guard rather than a security boundary: a source is + * free to refuse the version again when the package is requested. * - * @return array + * @param string $target_version Target version for switching. + * @param array> $available_versions List of available versions. + * + * @return array{success: bool, message: string} */ public static function validate_target_version( string $target_version, array $available_versions ): array { if ( empty( $target_version ) ) { return [ - 'success' => false, - 'message' => __( 'No target version specified.', 'code-snippets' ), - 'download_url' => '', + 'success' => false, + 'message' => __( 'No target version specified.', 'code-snippets' ), ]; } - foreach ( $available_versions as $version_info ) { - if ( $version_info['version'] === $target_version ) { - return [ - 'success' => true, - 'message' => '', - 'download_url' => $version_info['url'], - ]; - } + if ( null === self::find_version( $target_version, $available_versions ) ) { + return [ + 'success' => false, + 'message' => __( 'Invalid version specified.', 'code-snippets' ), + ]; } return [ - 'success' => false, - 'message' => __( 'Invalid version specified.', 'code-snippets' ), - 'download_url' => '', + 'success' => true, + 'message' => '', ]; } @@ -185,131 +313,14 @@ public static function create_error_response( string $message, string $technical } /** - * Install a plugin version from a URL. + * Install a plugin version from a package. * - * @param string $download_url Download URL. + * @param string $package Local path to a package, or a download URL. * - * @return array|bool|WP_Error + * @return array{version: string}|WP_Error */ - public static function perform_version_install( string $download_url ) { - if ( ! function_exists( 'wp_update_plugins' ) ) { - require_once ABSPATH . 'wp-admin/includes/update.php'; - } - if ( ! function_exists( 'show_message' ) ) { - require_once ABSPATH . 'wp-admin/includes/misc.php'; - } - if ( ! class_exists( 'Plugin_Upgrader' ) ) { - require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php'; - } - - $update_handler = new WP_Ajax_Upgrader_Skin(); - $upgrader = new Plugin_Upgrader( $update_handler ); - - global $code_snippets_last_update_handler, $code_snippets_last_upgrader; - $code_snippets_last_update_handler = $update_handler; - $code_snippets_last_upgrader = $upgrader; - - return $upgrader->install( - $download_url, - [ - 'overwrite_package' => true, - 'clear_update_cache' => true, - ] - ); - } - - /** - * Extract error message from an upgrade handler. - * - * @param WP_Upgrader_Skin|null $update_handler Update handler. - * @param Plugin_Upgrader|null $upgrader Plugin upgrader. - * - * @return string - * - * phpcs:disable WordPress.PHP.DevelopmentFunctions.error_log_print_r - */ - public static function extract_handler_messages( ?WP_Upgrader_Skin $update_handler, ?Plugin_Upgrader $upgrader ): string { - $handler_messages = ''; - - if ( isset( $update_handler ) ) { - if ( method_exists( $update_handler, 'get_errors' ) ) { - $errs = $update_handler->get_errors(); - if ( $errs instanceof WP_Error && $errs->has_errors() ) { - $handler_messages .= implode( "\n", $errs->get_error_messages() ); - } - } - if ( method_exists( $update_handler, 'get_error_messages' ) ) { - $em = $update_handler->get_error_messages(); - if ( $em ) { - $handler_messages .= "\n" . $em; - } - } - if ( method_exists( $update_handler, 'get_upgrade_messages' ) ) { - $upgrade_msgs = $update_handler->get_upgrade_messages(); - if ( is_array( $upgrade_msgs ) ) { - $handler_messages .= "\n" . implode( "\n", $upgrade_msgs ); - } elseif ( $upgrade_msgs ) { - $handler_messages .= "\n" . $upgrade_msgs; - } - } - } - - if ( empty( $handler_messages ) && isset( $upgrader->result ) ) { - if ( is_wp_error( $upgrader->result ) ) { - $handler_messages = implode( "\n", $upgrader->result->get_error_messages() ); - } else { - $handler_messages = is_scalar( $upgrader->result ) - ? (string) $upgrader->result - : print_r( $upgrader->result, true ); - } - } - - return trim( $handler_messages ); - } - - /** - * Report the failure of a version switch attempt. - * - * @param string $target_version Version number of attempted upgrade. - * @param mixed $result Result of upgrade. - * @param string $details Additional details. - * - * @return void - * - * phpcs:disable WordPress.PHP.DevelopmentFunctions - */ - private static function log_version_switch_attempt( string $target_version, $result, string $details = '' ): void { - if ( function_exists( 'error_log' ) ) { - error_log( sprintf( 'Code Snippets version switch failed. target=%s, result=%s, details=%s', $target_version, var_export( $result, true ), $details ) ); - } - } - - /** - * Handle the failure to install a new version. - * - * @param string $target_version Version used for attempted installation. - * @param string $download_url URL used for downloading new version. - * @param mixed $install_result Result of installation attempt. - * - * @return array - */ - private static function handle_installation_failure( string $target_version, string $download_url, $install_result ): array { - global $code_snippets_last_update_handler, $code_snippets_last_upgrader; - - $handler_messages = self::extract_handler_messages( $code_snippets_last_update_handler, $code_snippets_last_upgrader ); - self::log_version_switch_attempt( $target_version, $install_result, "URL: $download_url, Messages: $handler_messages" ); - - $fallback_message = __( 'Failed to switch versions. Please try again.', 'code-snippets' ); - - if ( ! empty( $handler_messages ) ) { - $short = wp_trim_words( wp_strip_all_tags( $handler_messages ), 40 ); - $fallback_message = sprintf( '%s %s', $fallback_message, $short ); - } - - return [ - 'success' => false, - 'message' => $fallback_message, - ]; + public static function perform_version_install( string $package ) { + return self::get_installer()->install( $package ); } /** @@ -324,6 +335,12 @@ public static function handle_version_switch( string $target_version ): array { return self::create_error_response( __( 'You do not have permission to update plugins.', 'code-snippets' ) ); } + $source = self::get_source(); + + if ( ! $source->is_available() ) { + return self::create_error_response( $source->get_unavailable_notice()['message'] ); + } + $available_versions = self::get_available_versions(); $validation = self::validate_target_version( $target_version, $available_versions ); @@ -337,27 +354,53 @@ public static function handle_version_switch( string $target_version ): array { set_transient( self::PROGRESS_KEY, $target_version, self::PROGRESS_TIMEOUT ); - $install_result = self::perform_version_install( $validation['download_url'] ); + $package = $source->fetch_package( self::find_version( $target_version, $available_versions ) ); - delete_transient( self::PROGRESS_KEY ); + if ( is_wp_error( $package ) ) { + delete_transient( self::PROGRESS_KEY ); - if ( is_wp_error( $install_result ) ) { - return self::create_error_response( $install_result->get_error_message() ); + // The cached catalogue is stale when the source no longer recognises a + // version it listed, so discard it and let the next render refetch. + delete_transient( $source->get_cache_key() ); + + return self::create_error_response( $package->get_error_message() ); } - if ( $install_result ) { - delete_transient( self::CACHE_KEY ); + try { + $install_result = self::perform_version_install( $package['package'] ); + } finally { + if ( $package['cleanup'] ) { + wp_delete_file( $package['package'] ); + } - // translators: %s: new version number. - $message = esc_html__( 'Successfully switched to version %s. Please refresh the page to see changes.', 'code-snippets' ); + delete_transient( self::PROGRESS_KEY ); + } - return [ - 'success' => true, - 'message' => sprintf( $message, $target_version ), - ]; - } else { - return self::handle_installation_failure( $target_version, $validation['download_url'], $install_result ); + if ( is_wp_error( $install_result ) ) { + return self::create_error_response( + sprintf( + // translators: %s: reason the installation failed. + __( 'Failed to switch versions: %s', 'code-snippets' ), + $install_result->get_error_message() + ) + ); } + + // The version on disk is the authoritative one when the installer can read + // it, since the package decides what was actually written. + $installed_version = $install_result['version'] ? $install_result['version'] : $target_version; + + delete_transient( $source->get_cache_key() ); + $source->report_installed_version( $installed_version ); + + return [ + 'success' => true, + 'message' => sprintf( + // translators: %s: new version number. + __( 'Successfully switched to version %s. Please refresh the page to see changes.', 'code-snippets' ), + $installed_version + ), + ]; } /** @@ -367,8 +410,6 @@ public static function handle_version_switch( string $target_version ): array { */ public static function render_version_switch_field(): void { $current_version = self::get_current_version(); - $available_versions = self::get_available_versions(); - $is_switching = self::is_version_switch_in_progress(); ?>
@@ -377,11 +418,30 @@ public static function render_version_switch_field(): void {

- +

- +
+ + + get_floor_notice( self::get_version_floor() ); + + ?>

+ +

+ +

@@ -542,6 +550,34 @@ private static function describe_version( array $version_info, string $current_v ); } + /** + * Render the explanation shown in place of the switcher when the catalogue + * holds no versions. + * + * The list is empty both when the request for it failed and when the source + * answered with nothing to install, which are not the same thing to the user. + * + * @return void + */ + private static function render_empty_catalogue_notice(): void { + $error_message = self::get_last_error_message(); + + if ( $error_message ) { + ?> +
+

+
+ +

+ +

+ register_test_services(); + $this->source->catalogue = new WP_Error( 'version_source_request_error', 'The version list could not be retrieved.' ); + + $output = $this->render_version_switch_field(); + + $this->assertStringContainsString( 'The version list could not be retrieved.', $output ); + $this->assertStringContainsString( 'notice-warning', $output ); + $this->assertStringNotContainsString( 'assertStringContainsString( 'Current Version:', $output ); + } + + /** + * A source that answered with nothing to install says so, instead of + * presenting the same dead control a failed request would. + * + * @return void + */ + public function test_empty_catalogue_renders_an_explicit_nothing_to_install_line(): void { + $this->register_test_services(); + + $output = $this->render_version_switch_field(); + + $this->assertStringContainsString( 'no versions available to install', $output ); + $this->assertStringNotContainsString( 'assertStringContainsString( 'Current Version:', $output ); + } + + /** + * A catalogue with versions in it still renders the dropdown, with an entry + * for each version. + * + * @return void + */ + public function test_populated_catalogue_renders_the_version_dropdown(): void { + $this->register_test_services(); + $this->seed_catalogue( '3.9.2' ); + + $output = $this->render_version_switch_field(); + + $this->assertStringContainsString( 'assertStringContainsString( 'value="3.9.2"', $output ); + $this->assertStringNotContainsString( 'no versions available to install', $output ); + } + + /** + * The message behind a failed request is kept alongside its code, and a + * successful fetch clears both. + * + * @return void + */ + public function test_last_error_message_is_stored_and_cleared_with_the_code(): void { + $this->register_test_services(); + + $this->assertSame( '', Version_Switch::get_last_error_message() ); + + $this->source->catalogue = new WP_Error( 'version_source_request_error', 'The version list could not be retrieved.' ); + + Version_Switch::get_available_versions(); + + $this->assertSame( 'version_source_request_error', Version_Switch::get_last_error_code() ); + $this->assertSame( 'The version list could not be retrieved.', Version_Switch::get_last_error_message() ); + + $this->source->catalogue = [ + 'versions' => [ + [ + 'version' => '3.9.2', + 'url' => 'https://example.org/code-snippets.3.9.2.zip', + ], + ], + 'floor' => '', + ]; + + Version_Switch::refresh_available_versions(); + + $this->assertSame( '', Version_Switch::get_last_error_code() ); + $this->assertSame( '', Version_Switch::get_last_error_message() ); + } + + /** + * A site still holding the previous transient shape — a bare error code — + * reports that code rather than a mangled one. + * + * @return void + */ + public function test_last_error_code_tolerates_the_previous_transient_shape(): void { + set_transient( self::ERROR_KEY, 'version_source_request_error', MINUTE_IN_SECONDS ); + + $this->assertSame( 'version_source_request_error', Version_Switch::get_last_error_code() ); + $this->assertSame( '', Version_Switch::get_last_error_message() ); + } + /** * The WordPress.org source drops the trunk entry and lists releases newest * first, and reports no floor because every release remains installable. From 97d7174da919b91c098a7e608579aa6b54bd519b Mon Sep 17 00:00:00 2001 From: lightbulbman Date: Tue, 22 Sep 2026 21:16:27 +0100 Subject: [PATCH 11/25] fix: only render the version floor notice when the source reports a floor --- src/php/Settings/Version_Switch.php | 3 +- tests/unit/Settings/Version_Switch_Test.php | 43 +++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/src/php/Settings/Version_Switch.php b/src/php/Settings/Version_Switch.php index a53242d38..db28615c0 100644 --- a/src/php/Settings/Version_Switch.php +++ b/src/php/Settings/Version_Switch.php @@ -476,7 +476,8 @@ public static function render_version_switch_field(): void { return; } - $floor_notice = self::get_source()->get_floor_notice( self::get_version_floor() ); + $floor = self::get_version_floor(); + $floor_notice = $floor ? self::get_source()->get_floor_notice( $floor ) : ''; ?>

diff --git a/tests/unit/Settings/Version_Switch_Test.php b/tests/unit/Settings/Version_Switch_Test.php index 51f267c2f..6a09bc90e 100644 --- a/tests/unit/Settings/Version_Switch_Test.php +++ b/tests/unit/Settings/Version_Switch_Test.php @@ -693,6 +693,49 @@ public function test_last_error_code_tolerates_the_previous_transient_shape(): v $this->assertSame( '', Version_Switch::get_last_error_message() ); } + /** + * A source that reports no floor has nothing to explain, so the floor notice + * is not rendered at all. + * + * @return void + */ + public function test_floor_notice_is_omitted_when_the_source_reports_no_floor(): void { + $this->register_test_services(); + $this->seed_catalogue( '3.9.2' ); + + $output = $this->render_version_switch_field(); + + $this->assertStringContainsString( 'assertStringNotContainsString( 'are not installable', $output ); + } + + /** + * A source that reports a floor explains where the catalogue stops. + * + * @return void + */ + public function test_floor_notice_is_rendered_when_the_source_reports_a_floor(): void { + $this->register_test_services(); + + set_transient( + $this->source->get_cache_key(), + [ + 'versions' => [ + [ + 'version' => '3.9.2', + 'url' => 'https://example.org/code-snippets.3.9.2.zip', + ], + ], + 'floor' => '3.6.0', + ], + HOUR_IN_SECONDS + ); + + $output = $this->render_version_switch_field(); + + $this->assertStringContainsString( 'Versions before 3.6.0 are not installable.', $output ); + } + /** * The WordPress.org source drops the trunk entry and lists releases newest * first, and reports no floor because every release remains installable. From 1685b60194d903a2bd28309563bf0ae2ec970356 Mon Sep 17 00:00:00 2001 From: lightbulbman Date: Wed, 14 Oct 2026 23:11:10 +0100 Subject: [PATCH 12/25] fix: record a refused package request the same way as a refused catalogue request --- src/php/Settings/Version_Switch.php | 32 +++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/src/php/Settings/Version_Switch.php b/src/php/Settings/Version_Switch.php index db28615c0..42a558f17 100644 --- a/src/php/Settings/Version_Switch.php +++ b/src/php/Settings/Version_Switch.php @@ -154,14 +154,7 @@ private static function get_catalogue(): array { $result = $source->fetch_catalogue(); if ( is_wp_error( $result ) ) { - set_transient( - self::ERROR_KEY, - [ - 'code' => $result->get_error_code(), - 'message' => $result->get_error_message(), - ], - self::ERROR_CACHE_DURATION - ); + self::record_error( $result ); return $empty; } @@ -176,6 +169,28 @@ private static function get_catalogue(): array { return $catalogue; } + /** + * Remember why the last request to the source failed. + * + * A source reads the code back through get_last_error_code() to decide + * whether it is still available, so a package request refused for the same + * reason as a catalogue request has to be recorded the same way. + * + * @param WP_Error $error Failure returned by the source. + * + * @return void + */ + private static function record_error( WP_Error $error ): void { + set_transient( + self::ERROR_KEY, + [ + 'code' => $error->get_error_code(), + 'message' => $error->get_error_message(), + ], + self::ERROR_CACHE_DURATION + ); + } + /** * Retrieve a list of plugin versions available for switching. * @@ -386,6 +401,7 @@ public static function handle_version_switch( string $target_version ): array { if ( is_wp_error( $package ) ) { delete_transient( self::PROGRESS_KEY ); + self::record_error( $package ); // The cached catalogue is stale when the source no longer recognises a // version it listed, so discard it and let the next render refetch. From 1bf8716379b2a09034d7e952e0c8db3e3599f6df Mon Sep 17 00:00:00 2001 From: lightbulbman Date: Wed, 14 Oct 2026 23:11:20 +0100 Subject: [PATCH 13/25] feat: hide the refresh versions button while versions cannot be listed --- src/php/Settings/Version_Switch.php | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/php/Settings/Version_Switch.php b/src/php/Settings/Version_Switch.php index 42a558f17..ec16e7f93 100644 --- a/src/php/Settings/Version_Switch.php +++ b/src/php/Settings/Version_Switch.php @@ -60,6 +60,32 @@ class Version_Switch { public static function init(): void { add_action( 'wp_ajax_code_snippets_switch_version', [ __CLASS__, 'ajax_switch_version' ] ); add_action( 'wp_ajax_code_snippets_refresh_versions', [ __CLASS__, 'ajax_refresh_versions' ] ); + add_filter( 'code_snippets_settings_tab_contents', [ __CLASS__, 'hide_refresh_when_unavailable' ] ); + } + + /** + * Drop the refresh button from the settings screen while versions cannot be + * listed, since the unavailable notice already says what to do instead. + * + * @param array> $contents Settings tab contents. + * + * @return array> + */ + public static function hide_refresh_when_unavailable( array $contents ): array { + if ( empty( $contents['advanced'] ) || self::is_available() ) { + return $contents; + } + + $contents['advanced'] = array_values( + array_filter( + $contents['advanced'], + function ( array $entry ): bool { + return [ 'version-switch', 'refresh_versions' ] !== $entry; + } + ) + ); + + return $contents; } /** From 1cb5819ac62d1f0f43a98c6916af6158dd4cfd3e Mon Sep 17 00:00:00 2001 From: lightbulbman Date: Wed, 14 Oct 2026 23:11:31 +0100 Subject: [PATCH 14/25] fix: render the version switch warning inside the switcher instead of at the top of the screen --- src/js/services/settings/tabs.ts | 4 ---- src/php/Settings/Settings_Fields.php | 5 ----- src/php/Settings/Settings_Layout.php | 1 - src/php/Settings/Version_Switch.php | 11 ++++++++--- 4 files changed, 8 insertions(+), 13 deletions(-) diff --git a/src/js/services/settings/tabs.ts b/src/js/services/settings/tabs.ts index a1c135456..5d4eb2351 100644 --- a/src/js/services/settings/tabs.ts +++ b/src/js/services/settings/tabs.ts @@ -13,10 +13,6 @@ const selectTab = (tabsWrapper: Element, tab: Element, section: string) => { document.querySelectorAll('.cloud-message').forEach(element => { element.classList.add('hidden') }) - - if ('version-switch' !== section) { - document.getElementById('version-switch-warning')?.classList.add('hidden') - } } // Refresh the editor preview whenever tabs change. CodeMirror measures itself diff --git a/src/php/Settings/Settings_Fields.php b/src/php/Settings/Settings_Fields.php index 29db33bcb..08ab8e084 100644 --- a/src/php/Settings/Settings_Fields.php +++ b/src/php/Settings/Settings_Fields.php @@ -152,11 +152,6 @@ private function init_fields() { 'type' => 'callback', 'render_callback' => [ '\\Code_Snippets\\Settings\\Version_Switch', 'render_refresh_versions_field' ], ], - 'version_warning' => [ - 'name' => '', - 'type' => 'callback', - 'render_callback' => [ '\\Code_Snippets\\Settings\\Version_Switch', 'render_version_switch_warning' ], - ], ]; $this->fields['general'] = [ diff --git a/src/php/Settings/Settings_Layout.php b/src/php/Settings/Settings_Layout.php index 88bf77337..05d866c72 100644 --- a/src/php/Settings/Settings_Layout.php +++ b/src/php/Settings/Settings_Layout.php @@ -94,7 +94,6 @@ public static function get_tab_contents(): array { [ 'permissions', 'role_capabilities' ], [ 'version-switch', 'version_switcher' ], [ 'version-switch', 'refresh_versions' ], - [ 'version-switch', 'version_warning' ], [ 'debug', 'reset_caches' ], [ 'debug', 'database_update' ], [ 'general', 'complete_uninstall' ], diff --git a/src/php/Settings/Version_Switch.php b/src/php/Settings/Version_Switch.php index ec16e7f93..acbfc220e 100644 --- a/src/php/Settings/Version_Switch.php +++ b/src/php/Settings/Version_Switch.php @@ -541,6 +541,8 @@ public static function render_version_switch_field(): void {

+ +