From 694b1c6e08fefb05af1c3d3572a2b5904ffedc24 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 15:01:01 +0300 Subject: [PATCH 01/12] chore(deps-dev): bump phpstan/phpstan from 2.1.32 to 2.2.15 (#1168) Bumps [phpstan/phpstan](https://github.com/phpstan/phpstan-phar-composer-source) from 2.1.32 to 2.2.15. - [Commits](https://github.com/phpstan/phpstan-phar-composer-source/commits) --- updated-dependencies: - dependency-name: phpstan/phpstan dependency-version: 2.2.15 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- composer.lock | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/composer.lock b/composer.lock index 3dc2b19e..2007f4a4 100644 --- a/composer.lock +++ b/composer.lock @@ -904,11 +904,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.32", + "version": "2.2.15", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/e126cad1e30a99b137b8ed75a85a676450ebb227", - "reference": "e126cad1e30a99b137b8ed75a85a676450ebb227", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/b158556ffd26825cf615a1c1f72fb5157a2301b7", + "reference": "b158556ffd26825cf615a1c1f72fb5157a2301b7", "shasum": "" }, "require": { @@ -931,6 +931,17 @@ "license": [ "MIT" ], + "authors": [ + { + "name": "Ondřej Mirtes" + }, + { + "name": "Markus Staab" + }, + { + "name": "Vincent Langlet" + } + ], "description": "PHPStan - PHP Static Analysis Tool", "keywords": [ "dev", @@ -953,7 +964,7 @@ "type": "github" } ], - "time": "2025-11-11T15:18:17+00:00" + "time": "2026-09-23T12:23:07+00:00" }, { "name": "phpunit/php-code-coverage", From 8ed9442c484cbe107797985a92b04216059724fd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:01:03 +0300 Subject: [PATCH 02/12] chore(deps-dev): bump phpstan/phpstan from 2.2.15 to 2.2.16 (#1170) Bumps [phpstan/phpstan](https://github.com/phpstan/phpstan-phar-composer-source) from 2.2.15 to 2.2.16. - [Commits](https://github.com/phpstan/phpstan-phar-composer-source/commits) --- updated-dependencies: - dependency-name: phpstan/phpstan dependency-version: 2.2.16 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- composer.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/composer.lock b/composer.lock index 2007f4a4..252fc44a 100644 --- a/composer.lock +++ b/composer.lock @@ -904,11 +904,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.2.15", + "version": "2.2.16", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/b158556ffd26825cf615a1c1f72fb5157a2301b7", - "reference": "b158556ffd26825cf615a1c1f72fb5157a2301b7", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/46a6d9060e5a7763adfcc21ebcb8b504ebdbcb92", + "reference": "46a6d9060e5a7763adfcc21ebcb8b504ebdbcb92", "shasum": "" }, "require": { @@ -964,7 +964,7 @@ "type": "github" } ], - "time": "2026-09-23T12:23:07+00:00" + "time": "2026-09-25T09:31:51+00:00" }, { "name": "phpunit/php-code-coverage", From 1622fce982b501cf6710759ef22cd7f72ad64168 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2026 15:01:10 +0300 Subject: [PATCH 03/12] chore(deps): bump codeinwp/themeisle-sdk from 3.3.65 to 3.3.66 (#1171) Bumps [codeinwp/themeisle-sdk](https://github.com/Codeinwp/themeisle-sdk) from 3.3.65 to 3.3.66. - [Release notes](https://github.com/Codeinwp/themeisle-sdk/releases) - [Commits](https://github.com/Codeinwp/themeisle-sdk/compare/v3.3.65...v3.3.66) --- updated-dependencies: - dependency-name: codeinwp/themeisle-sdk dependency-version: 3.3.66 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- composer.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/composer.lock b/composer.lock index 252fc44a..930d0c71 100644 --- a/composer.lock +++ b/composer.lock @@ -64,16 +64,16 @@ }, { "name": "codeinwp/themeisle-sdk", - "version": "3.3.65", + "version": "3.3.66", "source": { "type": "git", "url": "https://github.com/Codeinwp/themeisle-sdk.git", - "reference": "f650fe856d52ce4e5754557d89ba2f3127ad54d8" + "reference": "27e095d8357e0594cd5769222af1258538832e75" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Codeinwp/themeisle-sdk/zipball/f650fe856d52ce4e5754557d89ba2f3127ad54d8", - "reference": "f650fe856d52ce4e5754557d89ba2f3127ad54d8", + "url": "https://api.github.com/repos/Codeinwp/themeisle-sdk/zipball/27e095d8357e0594cd5769222af1258538832e75", + "reference": "27e095d8357e0594cd5769222af1258538832e75", "shasum": "" }, "require-dev": { @@ -99,9 +99,9 @@ ], "support": { "issues": "https://github.com/Codeinwp/themeisle-sdk/issues", - "source": "https://github.com/Codeinwp/themeisle-sdk/tree/v3.3.65" + "source": "https://github.com/Codeinwp/themeisle-sdk/tree/v3.3.66" }, - "time": "2026-09-29T09:35:47+00:00" + "time": "2026-10-07T08:46:25+00:00" }, { "name": "enshrined/svg-sanitize", From 338c1573631eb523406ae42f61b11460fcb142e5 Mon Sep 17 00:00:00 2001 From: Marius Cristea Date: Mon, 12 Oct 2026 13:01:44 +0300 Subject: [PATCH 04/12] fix: re-enable the dashboard when the Image Storage status request fails (#1175) callSync() sets the global loading flag before it polls the transfer progress. apiFetch rejects on any non-2xx response or network error, and the rejection handler cleared the sync and rollback flags but not the global one, so the Image Storage controls (and the other screens that read it) stayed disabled after a failed poll. Adds the first Jest test for the dashboard API module. Co-authored-by: Claude Opus 5.5 --- .../src/dashboard/utils/__tests__/api.test.js | 58 +++++++++++++++++++ assets/src/dashboard/utils/api.js | 1 + 2 files changed, 59 insertions(+) create mode 100644 assets/src/dashboard/utils/__tests__/api.test.js diff --git a/assets/src/dashboard/utils/__tests__/api.test.js b/assets/src/dashboard/utils/__tests__/api.test.js new file mode 100644 index 00000000..09566d36 --- /dev/null +++ b/assets/src/dashboard/utils/__tests__/api.test.js @@ -0,0 +1,58 @@ +/** + * callSync() polls the progress of an Image Storage transfer. apiFetch rejects on any non-2xx + * response or network error; every loading flag callSync() set must then be cleared, or the + * Image Storage controls (and other screens) stay disabled. + */ +const mockActions = {}; + +jest.mock( '@wordpress/api-fetch', () => jest.fn() ); +jest.mock( '@wordpress/data', () => ({ + dispatch: () => new Proxy({}, { + get: ( target, name ) => { + if ( ! mockActions[ name ]) { + mockActions[ name ] = jest.fn(); + } + return mockActions[ name ]; + } + }), + select: () => ({ + getOptimizedImages: () => [], + getQueryArgs: () => ({}), + getTotalNumberOfImages: () => 0, + getSiteSettings: () => ({}) + }) +}) ); +jest.mock( '@wordpress/url', () => ({ addQueryArgs: url => url }) ); + +// Provided by WordPress at runtime, not installed. +jest.mock( '@wordpress/notices', () => ({ store: 'core/notices' }), { virtual: true }); +jest.mock( '../helpers', () => ({ toggleDashboardSidebarSubmenu: jest.fn() }) ); + +global.optimoleDashboardApp = { + nonce: 'nonce', + routes: { number_of_images_and_pages: '/optml/v1/number_of_images_and_pages' } +}; +global.jQuery = { ajax: jest.fn() }; + +const apiFetch = require( '@wordpress/api-fetch' ); +const { callSync } = require( '../api' ); + +const flushPromises = () => new Promise( resolve => setTimeout( resolve, 0 ) ); + +describe( 'callSync', () => { + beforeEach( () => { + Object.values( mockActions ).forEach( action => action.mockClear() ); + }); + + test.each([ 'offload_images', 'rollback_images' ])( 'clears the loading state when the %s status request fails', async action => { + apiFetch.mockReturnValueOnce( Promise.reject( new Error( 'Internal Server Error' ) ) ); + + callSync({ action }); + await flushPromises(); + + expect( mockActions.setErrorMedia ).toHaveBeenCalledWith( action ); + expect( mockActions.setIsLoading.mock.calls ).toEqual([[ true ], [ false ]]); + expect( mockActions.setLoadingSync ).toHaveBeenLastCalledWith( false ); + expect( mockActions.setLoadingRollback ).toHaveBeenLastCalledWith( false ); + }); +}); diff --git a/assets/src/dashboard/utils/api.js b/assets/src/dashboard/utils/api.js index 4721bf30..2b93b898 100644 --- a/assets/src/dashboard/utils/api.js +++ b/assets/src/dashboard/utils/api.js @@ -586,6 +586,7 @@ export const callSync = ( data ) => { setErrorMedia( data.action ); setLoadingSync( false ); setLoadingRollback( false ); + setIsLoading( false ); }); }; From da97ddc01c1356addc08225bde22ad89e22194ff Mon Sep 17 00:00:00 2001 From: Marius Cristea Date: Mon, 12 Oct 2026 13:01:51 +0300 Subject: [PATCH 05/12] fix: let WP-CLI continue a rollback started from the dashboard (#1173) * fix: let WP-CLI continue a rollback started from the dashboard "Transfer back from Optimole" turns offload_media off when it starts the rollback, and `wp optimole media rollback_images` refused to run whenever offload_media was off. A rollback may now run while one is in progress (is_offload_enabled(), as the Abilities API already does); moving images to Optimole still needs offload_media on. The new Optml_Settings::can_transfer_images() holds that rule so it can be tested. The command also stopped a rollback when the offload limit flag was set; the limit only concerns uploads, so it now stops the offload only. Co-Authored-By: Claude Opus 5.5 * test: run the WP-CLI media command under PHPUnit The tests only covered Optml_Settings::can_transfer_images(), so the command could go back to its old checks unnoticed. A small WP-CLI stand-in (tests/wp-cli-stub.php) lets the real command run: it continues a rollback started from the dashboard, is not stopped by the offload limit flag, still stops an offload when the account is at its limit, and is refused when nothing was offloaded. Co-Authored-By: Claude Opus 5.5 * fix: do not let the WP-CLI transfer overlap a running background transfer With the gate open, `wp optimole media rollback_images` could move the same images as the dashboard's background chain at the same time, and restore them twice. The background chain takes the transfer lock when it is scheduled, and maybe_reschedule() takes it again on every request while a transfer is in progress, also where cron never runs the chain. So the command cannot simply wait for the lock: it would never run on a site with a stuck cron. The lock now records when a step ran (on renew). The command is refused while a background step ran within the lock's lifetime, and otherwise takes the lock over: a background step that fires later cannot renew it and stops, and nothing is rescheduled while the command holds it. The command renews the lock per batch and releases it on every exit. Co-Authored-By: Claude Opus 5.5 * fix: use a database advisory lock so only one run moves images The takeover of the transfer lock was a read-then-write: a background step could renew the lock between the two, and both runs then moved the same images. The transfer lock (a transient) goes back to what it was: it says which chain owns a transfer, and is taken when the chain is scheduled. A second lock now says who is moving images at this moment: a MySQL advisory lock (GET_LOCK), which is atomic and freed by the database when the request ends, also after a crash. A background step takes it for its batch and does nothing when it cannot; the WP-CLI command takes it for its whole run and stops with a message when it cannot. Where the database has no advisory locks, the lock is granted and transfers work as before. Co-Authored-By: Claude Opus 5.5 * fix: take the moving-images lock on the primary and release it at shutdown With several database servers (HyperDB, LudicrousDB) the lock query could go to a different server in each run, so two runs could both get the lock. Reads are now sent to the primary before the lock is asked. A fatal error skips the cleanup of the run. On a persistent connection the lock would then outlive the request, so it is also released on shutdown. Co-Authored-By: Claude Opus 5.5 * fix: do not add a translatable string for the WP-CLI lock message The message of the refused command is plain English, like the other WP-CLI messages of the plugin, so the fix adds no string to translate. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- inc/cli/cli_media.php | 14 +- inc/media_offload.php | 69 ++++++++++ inc/settings.php | 17 +++ tests/test-media.php | 311 +++++++++++++++++++++++++++++++++++++++++- tests/wp-cli-stub.php | 62 +++++++++ 5 files changed, 470 insertions(+), 3 deletions(-) create mode 100644 tests/wp-cli-stub.php diff --git a/inc/cli/cli_media.php b/inc/cli/cli_media.php index 8aa31e12..4033c744 100644 --- a/inc/cli/cli_media.php +++ b/inc/cli/cli_media.php @@ -43,10 +43,16 @@ private function update_images_template( $action ) { 'limit_exceeded' => __( 'You have reached the maximum offloading limit of images. To increase the offload limit and for more information, contact our support.', 'optimole-wp' ), ]; $settings = new Optml_Settings(); - if ( $settings->get( 'offload_media' ) === 'disabled' ) { + if ( ! $settings->can_transfer_images( $action ) ) { return \WP_CLI::error( __( 'You need to have the offload_media option enabled in order to use this command', 'optimole-wp' ) ); } + // One run moves images at a time. A background step that is moving images right now holds this + // lock; while this command holds it, the background steps do nothing. + if ( ! Optml_Media_Offload::acquire_moving_images_lock() ) { + return \WP_CLI::error( 'Another image transfer is running. Wait for it to finish, then run this command again.' ); + } + Optml_Media_Offload::clear_offload_errors_meta(); if ( $action === 'rollback' ) { Optml_Media_Offload::clear_rollback_errors_meta(); @@ -60,6 +66,7 @@ private function update_images_template( $action ) { Optml_Media_Offload::record_process_meta( $number_of_images ); $batch = 5; if ( $number_of_images === 0 ) { + Optml_Media_Offload::release_moving_images_lock(); WP_CLI::success( $strings[ $action ]['success'] ); return; @@ -88,7 +95,9 @@ private function update_images_template( $action ) { $action === 'rollback' ? Optml_Media_Offload::instance()->rollback_images( $batch ) : Optml_Media_Offload::instance()->upload_images( $batch ); } - if ( Optml_Media_Offload::instance()->settings->is_offload_limit_reached() ) { + // The offload limit stops uploads only: moving images back lowers the usage. + if ( $action === 'offload' && Optml_Media_Offload::instance()->settings->is_offload_limit_reached() ) { + Optml_Media_Offload::release_moving_images_lock(); WP_CLI::error( $strings['limit_exceeded'], true ); } @@ -96,6 +105,7 @@ private function update_images_template( $action ) { ++$tick; } $progress->finish(); + Optml_Media_Offload::release_moving_images_lock(); WP_CLI::line( $strings[ $action ]['success'] ); } } diff --git a/inc/media_offload.php b/inc/media_offload.php index cd66df44..f8368dba 100644 --- a/inc/media_offload.php +++ b/inc/media_offload.php @@ -2043,6 +2043,65 @@ public static function renew_transfer_lock( $token, $action ) { return true; } + /** + * Take the lock of the run that is moving images right now, without waiting. + * + * The transfer lock above says which chain owns a transfer. It is taken when a chain is scheduled, + * and again on every request while a transfer is in progress, so it cannot tell whether images are + * being moved at this moment. This one is a database advisory lock: taking it is atomic, and the + * database frees it when the request ends, also after a crash. A background step holds it for its + * batch, and WP-CLI for its whole run, so the two never move the same images at the same time. + * + * @return bool False while another run holds it. True when it was taken, and also where the + * database has no advisory locks, so transfers work there as before. + */ + public static function acquire_moving_images_lock(): bool { + global $wpdb; + + // The lock lives on one database server. Where reads are spread over several (HyperDB, + // LudicrousDB), every run has to ask the same one: the primary. + if ( method_exists( $wpdb, 'send_reads_to_masters' ) ) { + $wpdb->send_reads_to_masters(); + } + + $suppress = $wpdb->suppress_errors(); + $result = $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK( %s, 0 )', self::get_moving_images_lock_name() ) ); + $wpdb->suppress_errors( $suppress ); + + // A fatal error skips the cleanup of the caller. On a persistent connection the lock would then + // outlive the request, so it is also released when the request shuts down. + add_action( 'shutdown', [ __CLASS__, 'release_moving_images_lock' ] ); + + return null === $result || 1 === (int) $result; + } + + /** + * Release the lock taken with {@see self::acquire_moving_images_lock()}. + * + * @return void + */ + public static function release_moving_images_lock(): void { + global $wpdb; + + $suppress = $wpdb->suppress_errors(); + $wpdb->get_var( $wpdb->prepare( 'SELECT RELEASE_LOCK( %s )', self::get_moving_images_lock_name() ) ); + $wpdb->suppress_errors( $suppress ); + } + + /** + * Name of the advisory lock. Such locks are shared by every database on the server, so the name + * holds the database and the table prefix (which is per site on a network). + * + * @return string + */ + public static function get_moving_images_lock_name(): string { + global $wpdb; + + $database = defined( 'DB_NAME' ) ? DB_NAME : ''; + + return 'optml_moving_images_' . md5( $database . $wpdb->prefix ); + } + /** * Release the transfer lock if we still own it, allowing another worker to acquire it. * @@ -2151,6 +2210,14 @@ public function start_processing_images( $action, $batch, $page, $total, $step, return; } + // Only one run moves images at a time. WP-CLI holds this lock while its transfer runs: it does the + // work of this step, and the chain is scheduled again once the transfer lock expires. + if ( ! self::acquire_moving_images_lock() ) { + self::$instance->logger->add_log( $type, 'Another run is moving images, this step is skipped.' ); + + return; + } + set_time_limit( 0 ); try { @@ -2201,6 +2268,8 @@ public function start_processing_images( $action, $batch, $page, $total, $step, $lock_token, ] ); + } finally { + self::release_moving_images_lock(); } } diff --git a/inc/settings.php b/inc/settings.php index 1707afff..2ed70887 100644 --- a/inc/settings.php +++ b/inc/settings.php @@ -881,6 +881,23 @@ public function is_offload_enabled() { return $this->get( 'offload_media' ) === 'enabled' || $this->get( 'rollback_status' ) !== 'disabled'; } + /** + * Whether a bulk image transfer can run. Moving images to Optimole needs offload_media on. + * Moving them back is also allowed while a rollback is in progress: "Transfer back" in the + * dashboard turns offload_media off when it starts the rollback. + * + * @param string $action The transfer, 'offload' or 'rollback'. + * + * @return bool + */ + public function can_transfer_images( $action ) { + if ( 'rollback' === $action ) { + return $this->is_offload_enabled(); + } + + return $this->get( 'offload_media' ) !== 'disabled'; + } + /** * Get cloud sites whitelist for current domain only. * diff --git a/tests/test-media.php b/tests/test-media.php index 529b5373..52fd6198 100644 --- a/tests/test-media.php +++ b/tests/test-media.php @@ -29,6 +29,12 @@ class Test_Media extends WP_UnitTestCase { private static $sample_attachement_upper_case; private static $sample_attachment_scaled; public static $error_mark = false; + /** + * Offloaded images the mocked account reports, out of an offload limit of 5000. + * + * @var int + */ + public static $offloaded_images = 50; /** * Mock the api calls and remote images requests */ @@ -119,7 +125,7 @@ public function filter_pre_http_request( $false, $r, $url ) { 'data' => array( 'cdn_key' => 'cdn_key', 'cdn_secret' => 'cdn_secret', - 'offloaded_images' => 50, + 'offloaded_images' => self::$offloaded_images, 'offload_limit' => 5000, ), )), @@ -345,6 +351,309 @@ public function test_image_sync() { $this->assertStringContainsString( '/w:300/h:200/q:mauto/process:' . self::$sample_attachement .'/id:579c7f7707ce87caa65fdf50c238a117', $image_medium_size[0] ); } + /** + * "Transfer back" in the dashboard turns offload_media off and starts the rollback; WP-CLI + * `wp optimole media rollback_images` must still be able to continue it. + * + * @dataProvider transfer_gate_provider + */ + public function test_can_transfer_images( $offload_media, $rollback_status, $action, $expected ) { + $settings = new Optml_Settings(); + $settings->update( 'offload_media', $offload_media ); + $settings->update( 'rollback_status', $rollback_status ); + + $this->assertSame( $expected, $settings->can_transfer_images( $action ) ); + } + + /** + * @return array + */ + public function transfer_gate_provider() { + return [ + 'offload with offload on' => [ 'enabled', 'disabled', 'offload', true ], + 'rollback with offload on' => [ 'enabled', 'disabled', 'rollback', true ], + 'rollback while a rollback runs' => [ 'disabled', 'enabled', 'rollback', true ], + 'offload while a rollback runs' => [ 'disabled', 'enabled', 'offload', false ], + 'rollback with offload off and idle' => [ 'disabled', 'disabled', 'rollback', false ], + 'offload with offload off and idle' => [ 'disabled', 'disabled', 'offload', false ], + ]; + } + + /** + * Run a `wp optimole media` command with the WP-CLI stand-ins. + * + * @param string $command The command method, 'rollback_images' or 'move_to_optimole'. + * @return string[] The lines the command printed. + */ + private function run_cli_media_command( $command ) { + require_once __DIR__ . '/wp-cli-stub.php'; + WP_CLI::$lines = []; + + ( new Optml_Cli_Media() )->$command(); + + return WP_CLI::$lines; + } + + public function test_cli_rollback_continues_a_rollback_started_from_the_dashboard() { + // "Transfer back from Optimole": offload_media goes off, the rollback is in progress. + $settings = new Optml_Settings(); + $settings->update( 'offload_media', 'disabled' ); + $settings->update( 'rollback_status', 'enabled' ); + + $lines = $this->run_cli_media_command( 'rollback_images' ); + + $this->assertContains( 'All images have been uploaded to your media library', $lines ); + $this->assertStringNotContainsString( 'id:', wp_get_attachment_metadata( self::$sample_attachement )['file'] ); + $this->assertFileExists( get_attached_file( self::$sample_attachement ) ); + } + + public function test_cli_rollback_is_not_stopped_by_the_offload_limit() { + $settings = new Optml_Settings(); + $settings->update( 'offload_limit_reached', 'enabled' ); + + $lines = $this->run_cli_media_command( 'rollback_images' ); + + $this->assertContains( 'All images have been uploaded to your media library', $lines ); + $this->assertStringNotContainsString( 'id:', wp_get_attachment_metadata( self::$sample_attachement )['file'] ); + } + + public function test_cli_offload_stops_at_the_offload_limit() { + // Bring the images back first, so there is something to offload; the account is at its limit. + Optml_Media_Offload::instance()->rollback_images( 100 ); + self::$offloaded_images = 5000; + require_once __DIR__ . '/wp-cli-stub.php'; + + try { + $this->run_cli_media_command( 'move_to_optimole' ); + $this->fail( 'The offload was not stopped at the offload limit.' ); + } catch ( Optml_Test_WP_CLI_Error $error ) { + $this->assertStringContainsString( 'maximum offloading limit', $error->getMessage() ); + } finally { + self::$offloaded_images = 50; + } + + $this->assert_moving_images_lock_is_free(); + } + + /** + * A second database connection: for advisory locks, another process. + * + * @return wpdb + */ + private function other_connection(): wpdb { + return new wpdb( DB_USER, DB_PASSWORD, DB_NAME, DB_HOST ); + } + + /** + * Hold or release the "moving images" lock from another connection. + * + * @param wpdb $connection The other connection. + * @param bool $hold True to take the lock, false to release it. + * @return void + */ + private function moving_images_lock_elsewhere( wpdb $connection, bool $hold ): void { + $query = $hold ? 'SELECT GET_LOCK( %s, 0 )' : 'SELECT RELEASE_LOCK( %s )'; + + $this->assertSame( '1', $connection->get_var( $connection->prepare( $query, Optml_Media_Offload::get_moving_images_lock_name() ) ) ); + } + + /** + * @return void + */ + private function assert_moving_images_lock_is_free(): void { + global $wpdb; + + $this->assertSame( '1', $wpdb->get_var( $wpdb->prepare( 'SELECT IS_FREE_LOCK( %s )', Optml_Media_Offload::get_moving_images_lock_name() ) ), 'The "moving images" lock was not released.' ); + } + + /** + * A background step is moving images at this moment. The command must not move the same images + * at the same time; it runs once that step is done. + */ + public function test_cli_rollback_waits_for_a_run_that_is_moving_images(): void { + $other = $this->other_connection(); + $offloaded = Optml_Media_Offload::number_of_images_and_pages( 'rollback_images' ); + $this->moving_images_lock_elsewhere( $other, true ); + require_once __DIR__ . '/wp-cli-stub.php'; + + try { + $this->run_cli_media_command( 'rollback_images' ); + $this->fail( 'The command ran while another run was moving images.' ); + } catch ( Optml_Test_WP_CLI_Error $error ) { + $this->assertStringContainsString( 'Another image transfer is running', $error->getMessage() ); + } + + $this->assertGreaterThan( 0, $offloaded ); + $this->assertSame( $offloaded, Optml_Media_Offload::number_of_images_and_pages( 'rollback_images' ), 'The command moved no image.' ); + + $this->moving_images_lock_elsewhere( $other, false ); + $lines = $this->run_cli_media_command( 'rollback_images' ); + + $this->assertContains( 'All images have been uploaded to your media library', $lines ); + $this->assertSame( 0, Optml_Media_Offload::number_of_images_and_pages( 'rollback_images' ) ); + } + + /** + * While the command (another connection here) moves the images, a background step that fires + * does nothing: it moves no image and does not continue the chain. Once the lock is free, the + * step runs and schedules the next one. + */ + public function test_background_step_does_nothing_while_another_run_moves_images(): void { + ( new Optml_Settings() )->update( 'rollback_status', 'enabled' ); + $other = $this->other_connection(); + $token = Optml_Media_Offload::acquire_transfer_lock( 'rollback_images' ); + $offloaded = Optml_Media_Offload::number_of_images_and_pages( 'rollback_images' ); + $this->moving_images_lock_elsewhere( $other, true ); + + Optml_Media_Offload::instance()->start_processing_images( 'rollback_images', 10, 1, 5, 1, $token ); + + $this->assertGreaterThan( 0, $offloaded ); + $this->assertSame( $offloaded, Optml_Media_Offload::number_of_images_and_pages( 'rollback_images' ), 'The step moved no image.' ); + $this->assertSame( 0, $this->count_scheduled_transfer_steps() ); + + $this->moving_images_lock_elsewhere( $other, false ); + Optml_Media_Offload::instance()->start_processing_images( 'rollback_images', 10, 1, 5, 1, $token ); + + $this->assertSame( 1, $this->count_scheduled_transfer_steps() ); + $this->assert_moving_images_lock_is_free(); + } + + /** + * @return int The background transfer steps waiting in WP-Cron. + */ + private function count_scheduled_transfer_steps(): int { + $count = 0; + + foreach ( (array) _get_cron_array() as $hooks ) { + $count += isset( $hooks['optml_start_processing_images'] ) ? count( $hooks['optml_start_processing_images'] ) : 0; + } + + return $count; + } + + /** + * Where cron is stuck, the background transfer is scheduled and holds the transfer lock, but no + * step runs. The command does the work, and leaves the transfer lock to the chain. + */ + public function test_cli_rollback_runs_while_a_background_transfer_is_only_scheduled(): void { + $scheduled = Optml_Media_Offload::acquire_transfer_lock( 'rollback_images' ); + + $lines = $this->run_cli_media_command( 'rollback_images' ); + + $this->assertContains( 'All images have been uploaded to your media library', $lines ); + $this->assertSame( 0, Optml_Media_Offload::number_of_images_and_pages( 'rollback_images' ) ); + $this->assert_moving_images_lock_is_free(); + $this->assertTrue( Optml_Media_Offload::renew_transfer_lock( $scheduled, 'rollback_images' ) ); + } + + public function test_cli_releases_the_moving_images_lock_when_there_is_nothing_to_move(): void { + // Every image is offloaded already. + $lines = $this->run_cli_media_command( 'move_to_optimole' ); + + $this->assertContains( 'All images have been uploaded to Optimole Cloud', $lines ); + $this->assert_moving_images_lock_is_free(); + } + + /** + * A fatal error skips the cleanup of the run. The lock is then released when the request shuts + * down, which matters on a persistent connection. + */ + public function test_moving_images_lock_is_also_released_at_shutdown(): void { + $this->assertTrue( Optml_Media_Offload::acquire_moving_images_lock() ); + + $this->assertNotFalse( has_action( 'shutdown', [ Optml_Media_Offload::class, 'release_moving_images_lock' ] ) ); + + Optml_Media_Offload::release_moving_images_lock(); + $this->assert_moving_images_lock_is_free(); + } + + /** + * With several database servers the lock must be asked from the primary by every run. HyperDB + * and LudicrousDB route the following reads there with send_reads_to_masters(). + */ + public function test_moving_images_lock_is_taken_on_the_primary_database_server(): void { + global $wpdb; + + $original = $wpdb; + $wpdb = new class( $original ) { + /** + * @var string[] The calls, in order. + */ + public $calls = []; + + /** + * @var string + */ + public $prefix; + + /** + * @var wpdb + */ + private $db; + + public function __construct( wpdb $db ) { + $this->db = $db; + $this->prefix = $db->prefix; + } + + public function send_reads_to_masters(): void { + $this->calls[] = 'send_reads_to_masters'; + } + + public function suppress_errors( $suppress = true ) { + return $this->db->suppress_errors( $suppress ); + } + + public function prepare( $query, ...$args ) { + return $this->db->prepare( $query, ...$args ); + } + + public function get_var( $query ) { + $this->calls[] = false !== strpos( $query, 'GET_LOCK' ) ? 'GET_LOCK' : 'query'; + + return $this->db->get_var( $query ); + } + }; + + try { + $this->assertTrue( Optml_Media_Offload::acquire_moving_images_lock() ); + $calls = $wpdb->calls; + } finally { + $wpdb = $original; + Optml_Media_Offload::release_moving_images_lock(); + } + + $this->assertSame( [ 'send_reads_to_masters', 'GET_LOCK' ], $calls ); + } + + /** + * A database without advisory locks (the query fails): transfers run as before. + */ + public function test_moving_images_lock_is_granted_where_the_database_has_no_advisory_locks(): void { + $break_get_lock = static function ( string $query ): string { + return false !== strpos( $query, 'GET_LOCK' ) ? 'SELECT optml_no_such_function()' : $query; + }; + add_filter( 'query', $break_get_lock ); + + try { + $this->assertTrue( Optml_Media_Offload::acquire_moving_images_lock() ); + } finally { + remove_filter( 'query', $break_get_lock ); + } + } + + public function test_cli_rollback_is_refused_when_nothing_was_offloaded() { + $settings = new Optml_Settings(); + $settings->update( 'offload_media', 'disabled' ); + $settings->update( 'rollback_status', 'disabled' ); + require_once __DIR__ . '/wp-cli-stub.php'; + + $this->expectException( Optml_Test_WP_CLI_Error::class ); + $this->expectExceptionMessage( 'You need to have the offload_media option enabled' ); + + $this->run_cli_media_command( 'rollback_images' ); + } + public function test_image_rollback() { Optml_Media_Offload::instance()->rollback_images( 100 ); diff --git a/tests/wp-cli-stub.php b/tests/wp-cli-stub.php new file mode 100644 index 00000000..06f65332 --- /dev/null +++ b/tests/wp-cli-stub.php @@ -0,0 +1,62 @@ + Date: Mon, 12 Oct 2026 20:02:38 +0300 Subject: [PATCH 06/12] fix: keep new uploads local while an Image Storage rollback runs (#1174) * fix: keep new uploads local while an Image Storage rollback runs During a rollback, offload_media is off but is_offload_enabled() stays true so that already offloaded images keep being served from Optimole. That also kept the upload hooks on, so a new upload was offloaded while everything else was being moved back. generate_image_meta() and the filename deduplication in insert() are now registered only while offload_media is on; they belong together, because insert() renames the attached file for offloaded uploads only. Co-Authored-By: Claude Opus 5.5 * test: add a void return type to the rollback upload test Co-Authored-By: Claude Opus 5.5 * test: move the rollback upload test away from the other new media tests Three open PRs added tests at the same place in tests/test-media.php, which would conflict as they merge. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- inc/media_offload.php | 23 ++++++++++++++++------- tests/test-media.php | 25 +++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 7 deletions(-) diff --git a/inc/media_offload.php b/inc/media_offload.php index f8368dba..f179d80f 100644 --- a/inc/media_offload.php +++ b/inc/media_offload.php @@ -161,8 +161,14 @@ public static function instance() { self::$instance->init(); } if ( self::$instance->settings->is_offload_enabled() ) { + // While a rollback runs, offloaded images are still served from Optimole, but new uploads + // stay local: offloading them and their filename deduplication needs offload_media on. + $offload_new_uploads = self::$instance->settings->get( 'offload_media' ) === 'enabled'; + add_filter( 'image_downsize', [ self::$instance, 'generate_filter_downsize_urls' ], 10, 3 ); - add_filter( 'wp_generate_attachment_metadata', [ self::$instance, 'generate_image_meta' ], 10, 2 ); + if ( $offload_new_uploads ) { + add_filter( 'wp_generate_attachment_metadata', [ self::$instance, 'generate_image_meta' ], 10, 2 ); + } add_filter( 'wp_get_attachment_url', [ self::$instance, 'get_image_attachment_url' ], - 999, 2 ); add_filter( 'wp_insert_post_data', [ self::$instance, 'filter_uploaded_images' ] ); @@ -176,12 +182,15 @@ public static function instance() { add_filter( 'wp_calculate_image_srcset', [ self::$instance, 'calculate_image_srcset' ], 1, 5 ); add_action( 'post_updated', [ self::$instance, 'update_offload_meta' ], 10, 3 ); - // Backwards compatibility for older versions of WordPress < 6.0.0 requiring 3 parameters for this specific filter. - $below_6_0_0 = version_compare( get_bloginfo( 'version' ), '6.0.0', '<' ); - if ( $below_6_0_0 ) { - add_filter( 'wp_insert_attachment_data', [ self::$instance, 'insert_legacy' ], 10, 3 ); - } else { - add_filter( 'wp_insert_attachment_data', [ self::$instance, 'insert' ], 10, 4 ); + // Local uploads keep WordPress's own filename deduplication. + if ( $offload_new_uploads ) { + // Backwards compatibility for older versions of WordPress < 6.0.0 requiring 3 parameters for this specific filter. + $below_6_0_0 = version_compare( get_bloginfo( 'version' ), '6.0.0', '<' ); + if ( $below_6_0_0 ) { + add_filter( 'wp_insert_attachment_data', [ self::$instance, 'insert_legacy' ], 10, 3 ); + } else { + add_filter( 'wp_insert_attachment_data', [ self::$instance, 'insert' ], 10, 4 ); + } } add_action( 'optml_start_processing_images', [ self::$instance, 'start_processing_images' ], 10, 6 ); diff --git a/tests/test-media.php b/tests/test-media.php index 52fd6198..93bec34e 100644 --- a/tests/test-media.php +++ b/tests/test-media.php @@ -230,6 +230,31 @@ static function unlinkRecursive( $pattern, $files ) { } } } + /** + * While a rollback runs (offload_media off, rollback_status on), offloaded images are still + * served from Optimole, but a new upload stays in the media library. + */ + public function test_upload_during_rollback_stays_local(): void { + $settings = new Optml_Settings(); + $settings->update( 'offload_media', 'disabled' ); + $settings->update( 'rollback_status', 'enabled' ); + + // The hooks of this request, registered with the rollback state. + remove_all_filters( 'wp_generate_attachment_metadata' ); + remove_all_filters( 'wp_insert_attachment_data' ); + Optml_Media_Offload::instance(); + + $this->assertFalse( has_filter( 'wp_generate_attachment_metadata' ) ); + $this->assertFalse( has_filter( 'wp_insert_attachment_data' ) ); + $this->assertNotFalse( has_filter( 'wp_get_attachment_url' ), 'Offloaded images are still served from Optimole.' ); + + $attachment_id = self::factory()->attachment->create_upload_object( OPTML_PATH . 'tests/assets/' . self::$files[0] . '.jpg' ); + $meta = wp_get_attachment_metadata( $attachment_id ); + + $this->assertFalse( Optml_Media_Offload::is_uploaded_image( $meta['file'] ) ); + $this->assertFileExists( get_attached_file( $attachment_id ) ); + } + public function test_retryable_error() { Optml_Media_Offload::instance()->rollback_and_update_images([self::$sample_attachement]); self::$error_mark = true; From 1911c9363ba506958da56e31848a45aadefed636 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 09:01:00 +0300 Subject: [PATCH 07/12] chore(deps-dev): bump phpstan/phpstan from 2.2.16 to 2.2.17 (#1180) Bumps [phpstan/phpstan](https://github.com/phpstan/phpstan-phar-composer-source) from 2.2.16 to 2.2.17. - [Commits](https://github.com/phpstan/phpstan-phar-composer-source/commits) --- updated-dependencies: - dependency-name: phpstan/phpstan dependency-version: 2.2.17 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- composer.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/composer.lock b/composer.lock index 930d0c71..fd3e8d1c 100644 --- a/composer.lock +++ b/composer.lock @@ -904,11 +904,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.2.16", + "version": "2.2.17", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/46a6d9060e5a7763adfcc21ebcb8b504ebdbcb92", - "reference": "46a6d9060e5a7763adfcc21ebcb8b504ebdbcb92", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/e676a49fa70cee9f5bf6ff4796186b928a08673f", + "reference": "e676a49fa70cee9f5bf6ff4796186b928a08673f", "shasum": "" }, "require": { @@ -964,7 +964,7 @@ "type": "github" } ], - "time": "2026-09-25T09:31:51+00:00" + "time": "2026-10-04T19:40:09+00:00" }, { "name": "phpunit/php-code-coverage", From d5e8ead294c3cc57e45f5844bee733ec9f841170 Mon Sep 17 00:00:00 2001 From: Marius Cristea Date: Tue, 13 Oct 2026 23:02:16 +0300 Subject: [PATCH 08/12] fix: do not build polled image URLs before the SDK is initialized (#1172) * fix: do not build polled image URLs before the SDK is initialized poll_optimized_images() turned the API's list into image URLs through the Optimole SDK, which is only initialized for a connected site. On a site without a CDN key and secret it threw "Please initialize the Optimole SDK first". It now returns an empty list there, without calling the API. Co-Authored-By: Claude Opus 5.5 * test: add return types to the not-connected polling test Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- inc/rest.php | 5 ++++ tests/test-poll-optimized-images.php | 43 ++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/inc/rest.php b/inc/rest.php index 59d8be1c..c8e28d9b 100644 --- a/inc/rest.php +++ b/inc/rest.php @@ -706,6 +706,11 @@ public function disconnect( WP_REST_Request $request ) { * @return WP_REST_Response */ public function poll_optimized_images( WP_REST_Request $request ) { + // The image URLs are built with the Optimole SDK, which is only initialized for a connected site. + if ( ! ( new Optml_Settings() )->is_connected() ) { + return $this->response( [] ); + } + $api_key = $request->get_param( 'api_key' ); $request = new Optml_Api(); $images = $request->get_optimized_images( $api_key ); diff --git a/tests/test-poll-optimized-images.php b/tests/test-poll-optimized-images.php index 929d7e56..9235ff25 100644 --- a/tests/test-poll-optimized-images.php +++ b/tests/test-poll-optimized-images.php @@ -182,6 +182,49 @@ public function test_poll_returns_empty_list_when_list_empty() { $this->assertSame( [], $payload['data'] ); } + /** + * Without a connection the SDK is not initialized, so a list cannot be turned into image URLs: + * polling returns an empty list instead of throwing, and does not call the API. + */ + public function test_poll_returns_empty_list_when_not_connected(): void { + ( new Optml_Settings() )->update( 'service_data', [] ); + $requests = 0; + + /** + * An API response with one image, counting the requests. + * + * @return array{headers: array, body: string, response: array{code: int, message: string}} + */ + $this->http_mock = function () use ( &$requests ): array { + ++$requests; + return [ + 'headers' => [], + 'body' => wp_json_encode( + [ + 'code' => 200, + 'data' => [ + 'list' => [ + [ + 'url' => 'https://example.com/photo.jpg', + 'key' => 'AbC', + ], + ], + ], + ] + ), + 'response' => [ + 'code' => 200, + 'message' => 'OK', + ], + ]; + }; + + $payload = $this->poll(); + $this->assertSame( 'success', $payload['code'] ); + $this->assertSame( [], $payload['data'] ); + $this->assertSame( 0, $requests ); + } + /** * A valid list is returned (URLs rewritten through Optimole). */ From d8e47bbbd5059bb0475da847c51c5a635e4a596a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:01:45 +0300 Subject: [PATCH 09/12] chore(deps-dev): bump phpstan/phpstan from 2.2.17 to 2.3.0 (#1182) Bumps [phpstan/phpstan](https://github.com/phpstan/phpstan-phar-composer-source) from 2.2.17 to 2.3.0. - [Commits](https://github.com/phpstan/phpstan-phar-composer-source/commits) --- updated-dependencies: - dependency-name: phpstan/phpstan dependency-version: 2.3.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- composer.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/composer.lock b/composer.lock index fd3e8d1c..468aef0e 100644 --- a/composer.lock +++ b/composer.lock @@ -904,11 +904,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.2.17", + "version": "2.3.0", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/e676a49fa70cee9f5bf6ff4796186b928a08673f", - "reference": "e676a49fa70cee9f5bf6ff4796186b928a08673f", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/b1f9890d9af53a25c58129e78f0aadd31f90d7f4", + "reference": "b1f9890d9af53a25c58129e78f0aadd31f90d7f4", "shasum": "" }, "require": { @@ -964,7 +964,7 @@ "type": "github" } ], - "time": "2026-10-04T19:40:09+00:00" + "time": "2026-10-06T06:15:36+00:00" }, { "name": "phpunit/php-code-coverage", From ed3d52db35748857c929b9bd6510ab521295c9d0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:01:50 +0300 Subject: [PATCH 10/12] chore(deps): bump codeinwp/themeisle-sdk from 3.3.66 to 3.3.67 (#1181) Bumps [codeinwp/themeisle-sdk](https://github.com/Codeinwp/themeisle-sdk) from 3.3.66 to 3.3.67. - [Release notes](https://github.com/Codeinwp/themeisle-sdk/releases) - [Commits](https://github.com/Codeinwp/themeisle-sdk/compare/v3.3.66...v3.3.67) --- updated-dependencies: - dependency-name: codeinwp/themeisle-sdk dependency-version: 3.3.67 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- composer.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/composer.lock b/composer.lock index 468aef0e..e381736f 100644 --- a/composer.lock +++ b/composer.lock @@ -64,16 +64,16 @@ }, { "name": "codeinwp/themeisle-sdk", - "version": "3.3.66", + "version": "3.3.67", "source": { "type": "git", "url": "https://github.com/Codeinwp/themeisle-sdk.git", - "reference": "27e095d8357e0594cd5769222af1258538832e75" + "reference": "e1dda44811660a65c3da6994349c054a46686d69" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Codeinwp/themeisle-sdk/zipball/27e095d8357e0594cd5769222af1258538832e75", - "reference": "27e095d8357e0594cd5769222af1258538832e75", + "url": "https://api.github.com/repos/Codeinwp/themeisle-sdk/zipball/e1dda44811660a65c3da6994349c054a46686d69", + "reference": "e1dda44811660a65c3da6994349c054a46686d69", "shasum": "" }, "require-dev": { @@ -99,9 +99,9 @@ ], "support": { "issues": "https://github.com/Codeinwp/themeisle-sdk/issues", - "source": "https://github.com/Codeinwp/themeisle-sdk/tree/v3.3.66" + "source": "https://github.com/Codeinwp/themeisle-sdk/tree/v3.3.67" }, - "time": "2026-10-07T08:46:25+00:00" + "time": "2026-10-13T12:16:34+00:00" }, { "name": "enshrined/svg-sanitize", From 23c24b3e4747aacec3a6aee171f0a761bf76a6b4 Mon Sep 17 00:00:00 2001 From: Marius Cristea Date: Fri, 16 Oct 2026 09:03:44 +0300 Subject: [PATCH 11/12] fix: clear a stale offload limit warning once the account is below the limit (#1176) * fix: clear a stale offload limit warning once the account is below the limit The "offload limit reached" flag was set by the upload check and cleared only when a bulk offload started, so the warning stayed after the account usage dropped (for example after duplicate sites were removed). It also silently stopped new uploads from offloading. - The daily sync stores the account's offload limit and clears the flag when the account is below it again. - The upload check stores the account limit with the flag, so the warning no longer shows the 50,000 default. - The upload check counts the images still to upload only while a bulk offload runs. The progress of a finished offload or of a rollback (which records how many images it moves back) no longer counts toward the limit. Co-Authored-By: Claude Opus 5.5 * fix: count the pending images of a WP-CLI offload toward the offload limit The upload check counted the "remaining" images only while the dashboard offload status was on, and `wp optimole media move_to_optimole` never sets that status, so a CLI offload no longer stopped before the limit. record_process_meta() now stores which transfer the progress belongs to, and the check counts it for offloads only, from the dashboard or WP-CLI. Progress recorded before this change has no transfer: it counts only while a dashboard offload runs, so stale rollback progress on existing sites stops tripping the limit. Also types the new tests, and moves the media tests away from the place where two other open PRs add theirs. Co-Authored-By: Claude Opus 5.5 * fix: count pending offload images only while that offload runs "remaining" stays in the progress after an offload finished, was cancelled or stopped at the limit, so it kept counting toward the limit of later uploads: after the daily sync cleared the warning, the next upload set it again. It now counts only while the offload is going: a dashboard offload while its status is on, and one from WP-CLI while the command runs. The command marks its progress as a foreground run and clears the mark when it ends (shutdown function, so also on an error exit). A rollback never counts. Also adds native types to record_process_meta() and the new helper. Co-Authored-By: Claude Opus 5.5 * fix: let the mark of a WP-CLI offload go stale when the command is interrupted The shutdown function that clears the mark does not run on Ctrl+C or SIGTERM, so an interrupted command kept counting its pending images toward the offload limit. The mark is now the time of the run's last activity: each offloaded image refreshes it, and it counts only while it is younger than the lifetime of a transfer step (10 minutes). Co-Authored-By: Claude Opus 5.5 * fix: do not let a later upload revive the mark of an interrupted WP-CLI offload The mark of a WP-CLI offload expires when the command stops refreshing it. A later offloaded image refreshed the mark even after it had expired, which made the stale progress count again. The mark is now refreshed only while it is still alive and cleared otherwise. Co-Authored-By: Claude Opus 5.5 * test: date the WP-CLI mark when the test runs, not when the suite is built Data providers run when the suite is built. The 'running WP-CLI offload' row took time() there, so it would fail once more than ten minutes pass before the test runs. The provider now gives the age of the mark and the test dates it. Co-Authored-By: Claude Opus 5.5 * test: declare the optional fourth element of the pending transfer provider Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- inc/admin.php | 10 ++ inc/cli/cli_media.php | 4 +- inc/media_offload.php | 73 +++++++++++++- tests/test-media.php | 153 +++++++++++++++++++++++++++++- tests/test-offload-limit-sync.php | 129 +++++++++++++++++++++++++ 5 files changed, 362 insertions(+), 7 deletions(-) create mode 100644 tests/test-offload-limit-sync.php diff --git a/inc/admin.php b/inc/admin.php index 746bdabf..72fb017f 100755 --- a/inc/admin.php +++ b/inc/admin.php @@ -1203,6 +1203,16 @@ public function daily_sync() { $this->settings->update_frontend_banner_from_remote( $data['extra_visits'] ); } + // The account usage decides the offload limit: a limit warning from an earlier upload is cleared + // once the account is below the limit again, for example after images or sites were removed. + if ( isset( $data['offload_limit'], $data['offloaded_images'] ) ) { + $this->settings->update( 'offload_limit', (int) $data['offload_limit'] ); + + if ( (int) $data['offloaded_images'] < (int) $data['offload_limit'] ) { + $this->settings->update( 'offload_limit_reached', 'disabled' ); + } + } + // Here the account got deactivated, in this case we check if the user is using offloaded images and we roll them back. $should_revert_offloading = isset( $data['status'] ) && $data['status'] === 'inactive'; diff --git a/inc/cli/cli_media.php b/inc/cli/cli_media.php index 4033c744..11d378b4 100644 --- a/inc/cli/cli_media.php +++ b/inc/cli/cli_media.php @@ -63,7 +63,9 @@ private function update_images_template( $action ) { $number_of_images_for = 'rollback_images'; } $number_of_images = Optml_Media_Offload::number_of_images_and_pages( $number_of_images_for ); - Optml_Media_Offload::record_process_meta( $number_of_images ); + Optml_Media_Offload::record_process_meta( $number_of_images, $number_of_images_for, true ); + // The run is over when this command ends, however it exits. + register_shutdown_function( [ 'Optml_Media_Offload', 'end_foreground_process_meta' ] ); $batch = 5; if ( $number_of_images === 0 ) { Optml_Media_Offload::release_moving_images_lock(); diff --git a/inc/media_offload.php b/inc/media_offload.php index f179d80f..c08ab51d 100644 --- a/inc/media_offload.php +++ b/inc/media_offload.php @@ -1393,8 +1393,7 @@ public function generate_image_meta( $meta, $attachment_id ) { $offload_manager = Optimole::offload(); $offload_usage = $offload_manager->getUsage(); - $current_run = self::get_process_meta(); - $remaining = isset( $current_run['remaining'] ) ? absint( $current_run['remaining'] ) : 0; + $remaining = self::get_pending_offload_count(); if ( $remaining + $offload_usage->getCurrent() >= $offload_usage->getLimit() ) { if ( OPTML_DEBUG_MEDIA ) { @@ -1402,6 +1401,7 @@ public function generate_image_meta( $meta, $attachment_id ) { do_action( 'optml_log', $offload_usage ); } + self::$instance->settings->update( 'offload_limit', $offload_usage->getLimit() ); self::$instance->settings->update( 'offload_limit_reached', 'enabled' ); self::$instance->logger->add_log( Optml_Logger::LOG_TYPE_OFFLOAD, 'Offload stopped: offloading images would exceed limit.' ); @@ -1821,18 +1821,76 @@ function ( $image_id ) { /** * Record process meta, * - * @param int $count The number of images to process. + * @param int $count The number of images to process. + * @param string $action The transfer the images belong to, 'offload_images' or 'rollback_images'. + * @param bool $foreground Whether the transfer runs in the foreground (WP-CLI), without the + * offloading or rollback status a dashboard transfer has. The progress + * then holds the time of the run's last activity. * * @return void */ - public static function record_process_meta( $count ) { + public static function record_process_meta( int $count, $action = 'offload_images', bool $foreground = false ): void { $meta = get_option( 'optml_process_meta', [] ); $meta['count'] = $count; $meta['remaining'] = $count; $meta['start_time'] = time(); + $meta['action'] = $action; + $meta['foreground'] = $foreground ? time() : 0; update_option( 'optml_process_meta', $meta ); } + /** + * Mark the transfer that runs in the foreground as over, see {@see self::record_process_meta()}. + * + * @return void + */ + public static function end_foreground_process_meta(): void { + $meta = get_option( 'optml_process_meta', [] ); + + if ( empty( $meta['foreground'] ) ) { + return; + } + + $meta['foreground'] = 0; + update_option( 'optml_process_meta', $meta ); + } + + /** + * Whether the progress belongs to a run in the foreground (WP-CLI) that moved an image within the + * lifetime of a transfer step. + * + * @param array $meta The process meta. + * + * @return bool + */ + private static function is_foreground_process_alive( array $meta ): bool { + return ! empty( $meta['foreground'] ) && ( time() - (int) $meta['foreground'] ) < self::TRANSFER_LOCK_TTL; + } + + /** + * Images a bulk offload that is still going has to upload. They count toward the offload limit. + * + * @return int + */ + private static function get_pending_offload_count(): int { + $meta = get_option( 'optml_process_meta', [] ); + + if ( ! isset( $meta['remaining'] ) ) { + return 0; + } + + // A rollback records the images it still has to move back in the same place. + if ( isset( $meta['action'] ) && 'offload_images' !== $meta['action'] ) { + return 0; + } + + // "remaining" stays after an offload finished, was cancelled or stopped at the limit. A + // dashboard offload runs while its status is on. One from WP-CLI marks its last activity: the + // mark is cleared when the command ends, and goes stale when the command was interrupted. + $running = self::is_foreground_process_alive( $meta ) || self::$instance->settings->get( 'offloading_status' ) !== 'disabled'; + + return $running ? absint( $meta['remaining'] ) : 0; + } /** * Update the process meta count. * @@ -1846,6 +1904,11 @@ public static function decrement_process_meta_remaining() { } $meta['remaining'] = $meta['remaining'] - 1; + // A run in the foreground is alive as long as it keeps moving images. A mark that went stale + // belongs to an interrupted run: a later upload must not bring it back. + if ( ! empty( $meta['foreground'] ) ) { + $meta['foreground'] = self::is_foreground_process_alive( $meta ) ? time() : 0; + } update_option( 'optml_process_meta', $meta ); } @@ -1907,7 +1970,7 @@ public static function move_images( $action, $refresh ) { self::$instance->settings->update( 'rollback_images' === $action ? 'offloading_status' : 'rollback_status', 'disabled' ); } self::$instance->settings->update( 'offload_limit_reached', 'disabled' ); - self::record_process_meta( $count ); + self::record_process_meta( $count, $action ); self::$instance->settings->update( $option, $in_progress ? 'enabled' : 'disabled' ); self::$instance->logger->add_log( $type, Optml_Logger::LOG_SEPARATOR ); diff --git a/tests/test-media.php b/tests/test-media.php index 93bec34e..f8a9eaa1 100644 --- a/tests/test-media.php +++ b/tests/test-media.php @@ -1073,4 +1073,155 @@ public function test_move_images_does_not_schedule_duplicate_processing_chain() delete_transient( Optml_Media_Offload::TRANSFER_LOCK_TRANSIENT ); } -} \ No newline at end of file + + /** + * The account mock reports 50 of 5000 images offloaded, and 4990 images are recorded as still to + * be moved. They count toward the limit of an upload only while an offload is moving them: not + * for a rollback, which records its progress in the same place, and not once the offload is over. + * + * @dataProvider pending_transfer_provider + * + * @param array{count: int, remaining: int, start_time: int, action?: string, foreground?: int} $process_meta The recorded transfer progress. + * @param string $offloading_status The dashboard offload status. + * @param bool $limit_reached Whether the upload trips the limit. + * @param int|null $mark_age Seconds since the last activity of a WP-CLI run, null without one. + */ + public function test_only_a_running_offload_counts_toward_the_offload_limit( array $process_meta, string $offloading_status, bool $limit_reached, ?int $mark_age = null ): void { + // The providers run when the suite is built, long before this test: the mark is dated here. + if ( null !== $mark_age ) { + $process_meta['foreground'] = time() - $mark_age; + } + + $settings = new Optml_Settings(); + $settings->update( 'offloading_status', $offloading_status ); + $settings->update( 'offload_limit_reached', 'disabled' ); + update_option( 'optml_process_meta', $process_meta ); + + $attachment_id = self::factory()->attachment->create_upload_object( OPTML_PATH . 'tests/assets/' . self::$files[0] . '.jpg' ); + $meta = wp_get_attachment_metadata( $attachment_id ); + $settings = new Optml_Settings(); + + $this->assertSame( ! $limit_reached, Optml_Media_Offload::is_uploaded_image( $meta['file'] ) ); + $this->assertSame( $limit_reached, $settings->is_offload_limit_reached() ); + + if ( $limit_reached ) { + // The limit of the account is stored with the warning, not the 50,000 default. + $this->assertSame( 5000, (int) $settings->get( 'offload_limit' ) ); + } + } + + /** + * @return array + */ + public function pending_transfer_provider(): array { + $progress = [ + 'count' => 4990, + 'remaining' => 4990, + 'start_time' => time(), + ]; + $offload = $progress + [ 'action' => 'offload_images' ]; + + return [ + 'rollback' => [ $progress + [ 'action' => 'rollback_images' ], 'disabled', false ], + 'rollback, with a stale offload status' => [ $progress + [ 'action' => 'rollback_images' ], 'enabled', false ], + 'dashboard offload that is running' => [ $offload + [ 'foreground' => 0 ], 'enabled', true ], + 'dashboard offload that finished or stopped at the limit' => [ $offload + [ 'foreground' => 0 ], 'disabled', false ], + 'WP-CLI offload that is running' => [ $offload, 'disabled', true, 0 ], + 'WP-CLI offload that ended' => [ $offload + [ 'foreground' => 0 ], 'disabled', false ], + 'WP-CLI offload interrupted an hour ago (Ctrl+C)' => [ $offload, 'disabled', false, HOUR_IN_SECONDS ], + 'recorded before the transfer was stored, idle' => [ $progress, 'disabled', false ], + 'recorded before the transfer was stored, offload running' => [ $progress, 'enabled', true ], + ]; + } + + public function test_a_wp_cli_run_marks_its_progress_until_it_ends(): void { + Optml_Media_Offload::record_process_meta( 12, 'offload_images', true ); + $this->assertGreaterThanOrEqual( time() - 5, get_option( 'optml_process_meta' )['foreground'] ); + + Optml_Media_Offload::end_foreground_process_meta(); + + $meta = get_option( 'optml_process_meta' ); + $this->assertSame( 0, $meta['foreground'] ); + $this->assertSame( 12, $meta['remaining'] ); + } + + /** + * The mark of a WP-CLI run is the time of its last activity: each offloaded image refreshes it, + * so it goes stale by itself when the command is interrupted. + */ + public function test_each_offloaded_image_refreshes_the_mark_of_a_wp_cli_run(): void { + update_option( + 'optml_process_meta', + [ + 'count' => 12, + 'remaining' => 12, + 'start_time' => time() - 500, + 'action' => 'offload_images', + 'foreground' => time() - 500, + ] + ); + + Optml_Media_Offload::decrement_process_meta_remaining(); + + $meta = get_option( 'optml_process_meta' ); + $this->assertSame( 11, $meta['remaining'] ); + $this->assertGreaterThanOrEqual( time() - 5, $meta['foreground'] ); + } + + /** + * Every offloaded image updates the progress, also an ordinary upload long after a WP-CLI run was + * interrupted. It must not bring the stale mark of that run back. + */ + public function test_a_later_upload_does_not_revive_the_mark_of_an_interrupted_wp_cli_run(): void { + update_option( + 'optml_process_meta', + [ + 'count' => 4990, + 'remaining' => 4990, + 'start_time' => time() - HOUR_IN_SECONDS, + 'action' => 'offload_images', + 'foreground' => time() - HOUR_IN_SECONDS, + ] + ); + $settings = new Optml_Settings(); + $settings->update( 'offloading_status', 'disabled' ); + $settings->update( 'offload_limit_reached', 'disabled' ); + + // Two ordinary uploads: the first one updates the progress, the second one must still be offloaded. + self::factory()->attachment->create_upload_object( OPTML_PATH . 'tests/assets/' . self::$files[0] . '.jpg' ); + $attachment_id = self::factory()->attachment->create_upload_object( OPTML_PATH . 'tests/assets/' . self::$files[1] . '.jpg' ); + + $this->assertSame( 0, get_option( 'optml_process_meta' )['foreground'] ); + $this->assertTrue( Optml_Media_Offload::is_uploaded_image( wp_get_attachment_metadata( $attachment_id )['file'] ) ); + $this->assertFalse( ( new Optml_Settings() )->is_offload_limit_reached() ); + } + + public function test_offloading_an_image_does_not_mark_a_dashboard_offload_as_a_wp_cli_run(): void { + Optml_Media_Offload::record_process_meta( 12, 'offload_images' ); + + Optml_Media_Offload::decrement_process_meta_remaining(); + + $this->assertSame( 0, get_option( 'optml_process_meta' )['foreground'] ); + } + + /** + * @dataProvider transfer_action_provider + */ + public function test_a_bulk_transfer_records_which_transfer_its_progress_belongs_to( string $action ): void { + Optml_Media_Offload::move_images( $action, false ); + + $meta = get_option( 'optml_process_meta' ); + $this->assertSame( $action, $meta['action'] ); + $this->assertSame( 0, $meta['foreground'] ); + } + + /** + * @return array + */ + public function transfer_action_provider(): array { + return [ + 'offload' => [ 'offload_images' ], + 'rollback' => [ 'rollback_images' ], + ]; + } +} diff --git a/tests/test-offload-limit-sync.php b/tests/test-offload-limit-sync.php new file mode 100644 index 00000000..ad1c764b --- /dev/null +++ b/tests/test-offload-limit-sync.php @@ -0,0 +1,129 @@ +update( 'api_key', 'test-key' ); + $settings->update( + 'service_data', + [ + 'cdn_key' => 'test123', + 'cdn_secret' => '12345', + 'whitelist' => [ 'example.com' ], + ] + ); + $settings->update( 'offload_limit_reached', 'enabled' ); + $settings->update( 'offload_limit', 50000 ); + + add_filter( 'pre_http_request', [ $this, 'mock_account_details' ], 10, 3 ); + } + + /** + * Clean up after each test. + */ + public function tearDown(): void { + remove_filter( 'pre_http_request', [ $this, 'mock_account_details' ], 10 ); + parent::tearDown(); + } + + /** + * Answer the account details request with $this->account. + * + * @param false|array|WP_Error $preempt Whether to preempt. + * @param array $args Request args. + * @param string $url Request URL. + * @return false|array|WP_Error + */ + public function mock_account_details( $preempt, $args, $url ) { + if ( strpos( $url, 'optml/v2/account/details' ) === false ) { + return $preempt; + } + + return [ + 'headers' => [], + 'body' => wp_json_encode( + [ + 'code' => 200, + 'data' => array_merge( + [ + 'cdn_key' => 'test123', + 'cdn_secret' => '12345', + 'whitelist' => [ 'example.com' ], + 'status' => 'active', + ], + $this->account + ), + ] + ), + 'response' => [ + 'code' => 200, + 'message' => 'OK', + ], + ]; + } + + /** + * Usage below the limit, for example after duplicate sites were removed, clears the warning. + */ + public function test_daily_sync_clears_the_warning_when_usage_is_below_the_limit(): void { + $this->account = [ + 'offloaded_images' => 1181, + 'offload_limit' => 20000, + ]; + + Optml_Main::instance()->admin->daily_sync(); + + $settings = new Optml_Settings(); + $this->assertFalse( $settings->is_offload_limit_reached() ); + $this->assertSame( 20000, (int) $settings->get( 'offload_limit' ) ); + } + + /** + * An account still at its limit keeps the warning. + */ + public function test_daily_sync_keeps_the_warning_at_the_limit(): void { + $this->account = [ + 'offloaded_images' => 20000, + 'offload_limit' => 20000, + ]; + + Optml_Main::instance()->admin->daily_sync(); + + $settings = new Optml_Settings(); + $this->assertTrue( $settings->is_offload_limit_reached() ); + $this->assertSame( 20000, (int) $settings->get( 'offload_limit' ) ); + } + + /** + * Without usage details in the response, nothing changes. + */ + public function test_daily_sync_without_usage_details_keeps_the_warning(): void { + $this->account = []; + + Optml_Main::instance()->admin->daily_sync(); + + $settings = new Optml_Settings(); + $this->assertTrue( $settings->is_offload_limit_reached() ); + $this->assertSame( 50000, (int) $settings->get( 'offload_limit' ) ); + } +} From a6ec1230a273ccb1a76a4db4e7f5df8864370493 Mon Sep 17 00:00:00 2001 From: Marius Cristea Date: Fri, 16 Oct 2026 10:01:53 +0300 Subject: [PATCH 12/12] fix: request the CSS width for Retina srcset entries and use it in sizes (#1177) * fix: request the CSS width for Retina srcset entries and use it in sizes With Retina images on, the page profiler stores a 1x and a 2x entry per breakpoint. The 2x entry carries its delivered width (e.g. 824, advertised as "824w") and dpr 2, and the CDN multiplies w by dpr, so the URL asked for w:824/dpr:2 and delivered 1648px for an 824w candidate (#1111). The sizes attribute also used that doubled width, so a breakpoint got two max-width conditions, one with the 2x width (#1113). The URL now asks for the CSS width (width / dpr) with the dpr, and the sizes slot uses the CSS width, so the 1x and 2x entries give the same condition and array_unique() keeps one. Doing this in PHP also corrects profiles already stored. Co-Authored-By: Claude Opus 5.5 * test: type the new srcset tests and share the Retina settings mock Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- inc/tag_replacer.php | 9 ++++-- tests/test-srcset.php | 67 +++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 71 insertions(+), 5 deletions(-) diff --git a/inc/tag_replacer.php b/inc/tag_replacer.php index 3ec4741e..74d2d6e3 100644 --- a/inc/tag_replacer.php +++ b/inc/tag_replacer.php @@ -500,8 +500,13 @@ public function add_missing_srcset_attributes( $tag, $missing_srcsets, $new_url, } $breakpoint = isset( $srcset_data['b'] ) ? (int) $srcset_data['b'] : 0; + // A dpr entry advertises its delivered width ("824w"): the CSS width times the dpr. The CDN + // multiplies w by dpr, so it gets the CSS width, which is also the slot in the sizes attribute. + $css_width = $dpr > 1 ? (int) round( $width / $dpr ) : $width; + $css_height = $dpr > 1 ? (int) round( $height / $dpr ) : $height; + // Generate optimized URL for this size - $optimized_url = $this->change_url_for_size( $new_url, $width, $height, $dpr ); + $optimized_url = $this->change_url_for_size( $new_url, $css_width, $css_height, $dpr ); if ( $optimized_url ) { $escaped_url = esc_url( $optimized_url ); @@ -512,7 +517,7 @@ public function add_missing_srcset_attributes( $tag, $missing_srcsets, $new_url, // Add sizes attribute entry for responsive breakpoints if ( $breakpoint > 0 ) { - $new_sizes_entries[] = '(max-width: ' . $breakpoint . 'px) ' . $width . 'px'; + $new_sizes_entries[] = '(max-width: ' . $breakpoint . 'px) ' . $css_width . 'px'; } } } diff --git a/tests/test-srcset.php b/tests/test-srcset.php index befee5d9..255116eb 100644 --- a/tests/test-srcset.php +++ b/tests/test-srcset.php @@ -175,9 +175,7 @@ public function test_add_missing_srcset_attributes_retina_disabled() { $tag_replacer = new Optml_Tag_Replacer(); // Mock settings to disable retina images - $settings = $this->getMockBuilder( 'Optml_Settings' )->getMock(); - $settings->method( 'get' )->with( 'retina_images' )->willReturn( 'disabled' ); - $tag_replacer->settings = $settings; + $tag_replacer->settings = $this->retina_settings( 'disabled' ); $tag = 'Test'; $missing_srcsets = [ @@ -191,6 +189,69 @@ public function test_add_missing_srcset_attributes_retina_disabled() { $this->assertStringNotContainsString( '400w', $result ); } + /** + * With Retina enabled, the profiler sends a 1x and a 2x entry per breakpoint. The 2x entry + * advertises its delivered width (824w); the CDN multiplies w by dpr, so its URL asks for the + * CSS width (w:412/dpr:2), not w:824/dpr:2, which would deliver 1648px. + */ + public function test_add_missing_srcset_attributes_retina_entry_delivers_its_advertised_width(): void { + $tag_replacer = new Optml_Tag_Replacer(); + $tag_replacer->settings = $this->retina_settings( 'enabled' ); + + $result = $tag_replacer->add_missing_srcset_attributes( + 'Test', + [ + [ 'w' => 412, 'h' => 300, 'd' => 1, 's' => '412w', 'b' => 768 ], + [ 'w' => 824, 'h' => 600, 'd' => 2, 's' => '824w', 'b' => 768 ], + ], + 'https://example.i.optimole.com/w:auto/h:auto/q:mauto/https://example.com/image.jpg', + false + ); + + $this->assertStringContainsString( 'w:412/h:300/q:mauto/https://example.com/image.jpg 412w', $result ); + $this->assertStringContainsString( 'w:412/dpr:2/h:300/q:mauto/https://example.com/image.jpg 824w', $result ); + $this->assertStringNotContainsString( 'w:824', $result ); + } + + /** + * The 1x and 2x entries of a breakpoint share one slot: the sizes attribute gets one + * condition for it, with the CSS width, not a second one with the doubled width. + */ + public function test_add_missing_srcset_attributes_retina_entries_share_one_sizes_condition(): void { + $tag_replacer = new Optml_Tag_Replacer(); + $tag_replacer->settings = $this->retina_settings( 'enabled' ); + + $result = $tag_replacer->add_missing_srcset_attributes( + 'Test', + [ + [ 'w' => 300, 'h' => 200, 'd' => 1, 's' => '300w', 'b' => 480 ], + [ 'w' => 600, 'h' => 400, 'd' => 2, 's' => '600w', 'b' => 480 ], + [ 'w' => 412, 'h' => 300, 'd' => 1, 's' => '412w', 'b' => 768 ], + [ 'w' => 824, 'h' => 600, 'd' => 2, 's' => '824w', 'b' => 768 ], + ], + 'https://example.i.optimole.com/w:auto/h:auto/q:mauto/https://example.com/image.jpg', + false + ); + + $this->assertSame( 1, substr_count( $result, '(max-width: 480px)' ) ); + $this->assertSame( 1, substr_count( $result, '(max-width: 768px)' ) ); + $this->assertStringContainsString( 'sizes="(max-width: 480px) 300px, (max-width: 768px) 412px"', $result ); + } + + /** + * Settings mock for the Retina images option. + * + * @param string $retina_images The option value, 'enabled' or 'disabled'. + * + * @return Optml_Settings + */ + private function retina_settings( string $retina_images ): Optml_Settings { + $settings = $this->getMockBuilder( 'Optml_Settings' )->getMock(); + $settings->method( 'get' )->with( 'retina_images' )->willReturn( $retina_images ); + + return $settings; + } + /** * Test enhance_existing_srcset method */