From 15bb99267c724001bb70b2483dbe7ede8e63ffc0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 9 Jul 2026 14:00:11 +0000 Subject: [PATCH 01/11] Initial plan From 40ec3aa4f204efa1fda52a0b6fcd1ffa43086e48 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 9 Jul 2026 14:03:41 +0000 Subject: [PATCH 02/11] fix: clear rollback errors when retrying restore of offloaded images --- .../parts/connected/settings/OffloadMedia.js | 4 +++- assets/src/dashboard/utils/api.js | 4 ++-- inc/cli/cli_media.php | 3 +++ inc/media_offload.php | 24 +++++++++++++++++-- inc/rest.php | 8 ++++++- phpstan-baseline.neon | 6 +++++ tests/test-media.php | 23 ++++++++++++++++++ 7 files changed, 66 insertions(+), 6 deletions(-) diff --git a/assets/src/dashboard/parts/connected/settings/OffloadMedia.js b/assets/src/dashboard/parts/connected/settings/OffloadMedia.js index 896c91020..91c5a3256 100644 --- a/assets/src/dashboard/parts/connected/settings/OffloadMedia.js +++ b/assets/src/dashboard/parts/connected/settings/OffloadMedia.js @@ -139,8 +139,10 @@ const OffloadMedia = ({ settings, canSave, setSettings, setCanSave }) => { setCheckedOffloadConflicts( false ); setOffloadConflicts([]); - checkOffloadConflicts( response => { + checkOffloadConflicts( async response => { if ( 0 === response.data.length ) { + await clearOffloadErrors( 'rollback_images' ); + const nextSettings = { ...settings }; nextSettings['show_offload_finish_notice'] = ''; nextSettings['rollback_status'] = 'enabled'; diff --git a/assets/src/dashboard/utils/api.js b/assets/src/dashboard/utils/api.js index 63f7dfd86..4721bf301 100644 --- a/assets/src/dashboard/utils/api.js +++ b/assets/src/dashboard/utils/api.js @@ -589,10 +589,10 @@ export const callSync = ( data ) => { }); }; -export const clearOffloadErrors = async() => { +export const clearOffloadErrors = async( action = 'offload_images' ) => { try { return await apiFetch({ - path: optimoleDashboardApp.routes['clear_offload_errors'], + path: addQueryArgs( optimoleDashboardApp.routes['clear_offload_errors'], { action }), method: 'GET' }); } catch ( error ) { diff --git a/inc/cli/cli_media.php b/inc/cli/cli_media.php index 0f19204f8..8aa31e127 100644 --- a/inc/cli/cli_media.php +++ b/inc/cli/cli_media.php @@ -48,6 +48,9 @@ private function update_images_template( $action ) { } Optml_Media_Offload::clear_offload_errors_meta(); + if ( $action === 'rollback' ) { + Optml_Media_Offload::clear_rollback_errors_meta(); + } WP_CLI::line( $strings[ $action ]['info'] ); $number_of_images_for = 'offload_images'; if ( $action === 'rollback' ) { diff --git a/inc/media_offload.php b/inc/media_offload.php index ac40c370f..ccef2e03b 100644 --- a/inc/media_offload.php +++ b/inc/media_offload.php @@ -2673,15 +2673,35 @@ private function get_offloaded_attachment_url( $attachment_id, $url ) { /** * Cleanup the offload errors meta. + * + * @param string $meta_key The meta key to delete. Defaults to the offload error key. + * + * @return int|bool Number of rows affected/selected or false on error. */ - public static function clear_offload_errors_meta() { + public static function clear_offload_errors_meta( $meta_key = '' ) { global $wpdb; + if ( empty( $meta_key ) ) { + $meta_key = self::META_KEYS['offload_error']; + } + return $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->postmeta} WHERE meta_key = %s", - self::META_KEYS['offload_error'] + $meta_key ) ); } + + /** + * Cleanup the rollback errors meta. + * + * Used when the user retries the rollback process so previously errored + * attachments are considered again for restore. + * + * @return int|bool Number of rows affected/selected or false on error. + */ + public static function clear_rollback_errors_meta() { + return self::clear_offload_errors_meta( self::META_KEYS['rollback_error'] ); + } } diff --git a/inc/rest.php b/inc/rest.php index 38cbaf4e0..4a5ff2abc 100644 --- a/inc/rest.php +++ b/inc/rest.php @@ -844,7 +844,13 @@ public function number_of_images_and_pages( WP_REST_Request $request ) { * @return WP_REST_Response */ public function clear_offload_errors( WP_REST_Request $request ) { - $delete_count = Optml_Media_Offload::clear_offload_errors_meta(); + $action = $request->get_param( 'action' ); + + if ( 'rollback_images' === $action ) { + $delete_count = Optml_Media_Offload::clear_rollback_errors_meta(); + } else { + $delete_count = Optml_Media_Offload::clear_offload_errors_meta(); + } return $this->response( [ 'success' => $delete_count ] ); } diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index e60e42483..e9306a4de 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -2040,6 +2040,12 @@ parameters: count: 1 path: inc/media_offload.php + - + message: '#^Method Optml_Media_Offload\:\:clear_rollback_errors_meta\(\) has no return type specified\.$#' + identifier: missingType.return + count: 1 + path: inc/media_offload.php + - message: '#^Method Optml_Media_Offload\:\:delete_attachment_from_server\(\) has no return type specified\.$#' identifier: missingType.return diff --git a/tests/test-media.php b/tests/test-media.php index 9b8148fda..57c40a3a1 100644 --- a/tests/test-media.php +++ b/tests/test-media.php @@ -356,6 +356,29 @@ public function test_image_rollback() { $this->assertStringContainsString( '-300x200', $image_meta['sizes']['medium']['file'] ); $this->assertStringContainsString( '-150x150', $image_meta['sizes']['thumbnail']['file'] ); } + + public function test_rollback_retry_clears_errors() { + + // Ensure the sample attachment is marked as offloaded. + update_post_meta( self::$sample_attachement, 'optimole_offload', 'true' ); + + // Simulate a failed rollback attempt. + update_post_meta( self::$sample_attachement, 'optimole_rollback_error', 'true' ); + + $args = Optml_Media_Offload::get_images_or_pages_query_args( -1, 'rollback_images', true ); + $args['post__in'] = [ self::$sample_attachement ]; + $query = new WP_Query( $args ); + $this->assertNotContains( self::$sample_attachement, $query->posts ); + + // Retrying the rollback should clear the rollback errors so the attachment is eligible again. + Optml_Media_Offload::clear_rollback_errors_meta(); + $this->assertEmpty( get_post_meta( self::$sample_attachement, 'optimole_rollback_error', true ) ); + + $args = Optml_Media_Offload::get_images_or_pages_query_args( -1, 'rollback_images', true ); + $args['post__in'] = [ self::$sample_attachement ]; + $query = new WP_Query( $args ); + $this->assertContains( self::$sample_attachement, $query->posts ); + } public function test_custom_post_image_extraction () { $content = '[fusion_builder_container type="flex" hundred_percent="no" hundred_percent_height="no" hundred_percent_height_scroll="no" align_content="stretch" flex_align_items="flex-start" flex_justify_content="flex-start" hundred_percent_height_center_content="yes" equal_height_columns="no" container_tag="div" hide_on_mobile="small-visibility,medium-visibility,large-visibility" status="published" border_style="solid" box_shadow="no" box_shadow_blur="0" box_shadow_spread="0" gradient_start_position="0" gradient_end_position="100" gradient_type="linear" radial_direction="center center" linear_angle="180" background_position="center center" background_repeat="no-repeat" fade="no" background_parallax="none" enable_mobile="no" parallax_speed="0.3" background_blend_mode="none" video_aspect_ratio="16:9" video_loop="yes" video_mute="yes" absolute="off" absolute_devices="small,medium,large" sticky="off" sticky_devices="small-visibility,medium-visibility,large-visibility" sticky_transition_offset="0" scroll_offset="0" animation_direction="left" animation_speed="0.3" filter_hue="0" filter_saturation="100" filter_brightness="100" filter_contrast="100" filter_invert="0" filter_sepia="0" filter_opacity="100" filter_blur="0" filter_hue_hover="0" filter_saturation_hover="100" filter_brightness_hover="100" filter_contrast_hover="100" filter_invert_hover="0" filter_sepia_hover="0" filter_opacity_hover="100" filter_blur_hover="0"][fusion_builder_row][fusion_builder_column type="1_3" type="1_3" align_self="auto" content_layout="column" align_content="flex-start" valign_content="flex-start" content_wrap="wrap" spacing="" center_content="no" link="" target="_self" min_height="" hide_on_mobile="small-visibility,medium-visibility,large-visibility" sticky_display="normal,sticky" class="" id="" type_medium="" type_small="" order_medium="0" order_small="0" dimension_spacing_medium="" dimension_spacing_small="" dimension_spacing="" dimension_margin_medium="" dimension_margin_small="" margin_top="" margin_bottom="" padding_medium="" padding_small="" padding_top="" padding_right="" padding_bottom="" padding_left="" hover_type="none" border_sizes="" border_color="" border_style="solid" border_radius="" box_shadow="no" dimension_box_shadow="" box_shadow_blur="0" box_shadow_spread="0" box_shadow_color="" box_shadow_style="" background_type="single" gradient_start_color="" gradient_end_color="" gradient_start_position="0" gradient_end_position="100" gradient_type="linear" radial_direction="center center" linear_angle="180" background_color="" background_image="" background_image_id="" background_position="left top" background_repeat="no-repeat" background_blend_mode="none" render_logics="" filter_type="regular" filter_hue="0" filter_saturation="100" filter_brightness="100" filter_contrast="100" filter_invert="0" filter_sepia="0" filter_opacity="100" filter_blur="0" filter_hue_hover="0" filter_saturation_hover="100" filter_brightness_hover="100" filter_contrast_hover="100" filter_invert_hover="0" filter_sepia_hover="0" filter_opacity_hover="100" filter_blur_hover="0" animation_type="" animation_direction="left" animation_speed="0.3" animation_offset="" last="no" border_position="all"][fusion_imageframe image_id="144|full" max_width="" sticky_max_width="" style_type="" blur="" stylecolor="" hover_type="none" bordersize="" bordercolor="" borderradius="" align_medium="none" align_small="none" align="none" margin_top="" margin_right="" margin_bottom="" margin_left="" lightbox="no" gallery_id="" lightbox_image="" lightbox_image_id="" alt="" link="" linktarget="_self" hide_on_mobile="small-visibility,medium-visibility,large-visibility" sticky_display="normal,sticky" class="" id="" animation_type="" animation_direction="left" animation_speed="0.3" animation_offset="" filter_hue="0" filter_saturation="100" filter_brightness="100" filter_contrast="100" filter_invert="0" filter_sepia="0" filter_opacity="100" filter_blur="0" filter_hue_hover="0" filter_saturation_hover="100" filter_brightness_hover="100" filter_contrast_hover="100" filter_invert_hover="0" filter_sepia_hover="0" filter_opacity_hover="100" filter_blur_hover="0"]http://35f86c81ba7c.ngrok.io/wp-content/uploads/2021/04/2AAPaNcjDJQ.jpg[/fusion_imageframe][/fusion_builder_column][fusion_builder_column type="2_3" type="2_3" align_self="auto" content_layout="column" align_content="flex-start" valign_content="flex-start" content_wrap="wrap" spacing="" center_content="no" link="" target="_self" min_height="" hide_on_mobile="small-visibility,medium-visibility,large-visibility" sticky_display="normal,sticky" class="" id="" type_medium="" type_small="" order_medium="0" order_small="0" dimension_spacing_medium="" dimension_spacing_small="" dimension_spacing="" dimension_margin_medium="" dimension_margin_small="" margin_top="" margin_bottom="" padding_medium="" padding_small="" padding_top="" padding_right="" padding_bottom="" padding_left="" hover_type="none" border_sizes="" border_color="" border_style="solid" border_radius="" box_shadow="no" dimension_box_shadow="" box_shadow_blur="0" box_shadow_spread="0" box_shadow_color="" box_shadow_style="" background_type="single" gradient_start_color="" gradient_end_color="" gradient_start_position="0" gradient_end_position="100" gradient_type="linear" radial_direction="center center" linear_angle="180" background_color="" background_image="" background_image_id="" background_position="left top" background_repeat="no-repeat" background_blend_mode="none" render_logics="" filter_type="regular" filter_hue="0" filter_saturation="100" filter_brightness="100" filter_contrast="100" filter_invert="0" filter_sepia="0" filter_opacity="100" filter_blur="0" filter_hue_hover="0" filter_saturation_hover="100" filter_brightness_hover="100" filter_contrast_hover="100" filter_invert_hover="0" filter_sepia_hover="0" filter_opacity_hover="100" filter_blur_hover="0" animation_type="" animation_direction="left" animation_speed="0.3" animation_offset="" last="no" border_position="all" element_content=""][fusion_imageframe image_id="180|fusion-1200" max_width="" sticky_max_width="" style_type="" blur="" stylecolor="" hover_type="none" bordersize="" bordercolor="" borderradius="" align_medium="none" align_small="none" align="none" lightbox="no" gallery_id="" lightbox_image="" lightbox_image_id="" alt="" link="" linktarget="_self" animation_type="" animation_direction="left" animation_speed="0.3" animation_offset="" hide_on_mobile="small-visibility,medium-visibility,large-visibility" sticky_display="normal,sticky" class="" id="" filter_hue="0" filter_saturation="100" filter_brightness="100" filter_contrast="100" filter_invert="0" filter_sepia="0" filter_opacity="100" filter_blur="0" filter_hue_hover="0" filter_saturation_hover="100" filter_brightness_hover="100" filter_contrast_hover="100" filter_invert_hover="0" filter_sepia_hover="0" filter_opacity_hover="100" From e8379998496d69ce9a95ae3371c628e7cb594480 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 9 Jul 2026 14:10:00 +0000 Subject: [PATCH 03/11] fix: remove stale PHPStan baseline entries for error-clearing meta methods --- phpstan-baseline.neon | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index e9306a4de..3141d6947 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -2034,18 +2034,6 @@ parameters: count: 1 path: inc/media_offload.php - - - message: '#^Method Optml_Media_Offload\:\:clear_offload_errors_meta\(\) has no return type specified\.$#' - identifier: missingType.return - count: 1 - path: inc/media_offload.php - - - - message: '#^Method Optml_Media_Offload\:\:clear_rollback_errors_meta\(\) has no return type specified\.$#' - identifier: missingType.return - count: 1 - path: inc/media_offload.php - - message: '#^Method Optml_Media_Offload\:\:delete_attachment_from_server\(\) has no return type specified\.$#' identifier: missingType.return From 75b5b8a05b9edc6b3e7d77ac061c2422a097198f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 9 Jul 2026 18:58:48 +0000 Subject: [PATCH 04/11] test: make rollback retry test deterministic with a dedicated attachment --- tests/test-media.php | 40 ++++++++++++++++++++++++++-------------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/tests/test-media.php b/tests/test-media.php index 57c40a3a1..4552dd912 100644 --- a/tests/test-media.php +++ b/tests/test-media.php @@ -359,25 +359,37 @@ public function test_image_rollback() { public function test_rollback_retry_clears_errors() { - // Ensure the sample attachment is marked as offloaded. - update_post_meta( self::$sample_attachement, 'optimole_offload', 'true' ); + // Use a dedicated attachment with fully controlled meta so the assertions do + // not depend on the offload state produced during setUp. + $attachment_id = self::factory()->post->create( + [ + 'post_type' => 'attachment', + 'post_mime_type' => 'image/jpeg', + 'post_status' => 'inherit', + ] + ); + + // Mark it as offloaded so it is eligible for rollback. + update_post_meta( $attachment_id, Optml_Media_Offload::META_KEYS['offloaded'], 'true' ); - // Simulate a failed rollback attempt. - update_post_meta( self::$sample_attachement, 'optimole_rollback_error', 'true' ); + $rollback_query = function () use ( $attachment_id ) { + $args = Optml_Media_Offload::get_images_or_pages_query_args( -1, 'rollback_images', true ); + $args['post__in'] = [ $attachment_id ]; - $args = Optml_Media_Offload::get_images_or_pages_query_args( -1, 'rollback_images', true ); - $args['post__in'] = [ self::$sample_attachement ]; - $query = new WP_Query( $args ); - $this->assertNotContains( self::$sample_attachement, $query->posts ); + return ( new WP_Query( $args ) )->posts; + }; + + // Without a rollback error the attachment is eligible for restore. + $this->assertContains( $attachment_id, $rollback_query() ); + + // Simulate a failed rollback attempt which should exclude it from selection. + update_post_meta( $attachment_id, Optml_Media_Offload::META_KEYS['rollback_error'], 'true' ); + $this->assertNotContains( $attachment_id, $rollback_query() ); // Retrying the rollback should clear the rollback errors so the attachment is eligible again. Optml_Media_Offload::clear_rollback_errors_meta(); - $this->assertEmpty( get_post_meta( self::$sample_attachement, 'optimole_rollback_error', true ) ); - - $args = Optml_Media_Offload::get_images_or_pages_query_args( -1, 'rollback_images', true ); - $args['post__in'] = [ self::$sample_attachement ]; - $query = new WP_Query( $args ); - $this->assertContains( self::$sample_attachement, $query->posts ); + $this->assertEmpty( get_post_meta( $attachment_id, Optml_Media_Offload::META_KEYS['rollback_error'], true ) ); + $this->assertContains( $attachment_id, $rollback_query() ); } public function test_custom_post_image_extraction () { $content = '[fusion_builder_container type="flex" hundred_percent="no" hundred_percent_height="no" hundred_percent_height_scroll="no" align_content="stretch" flex_align_items="flex-start" flex_justify_content="flex-start" hundred_percent_height_center_content="yes" equal_height_columns="no" container_tag="div" hide_on_mobile="small-visibility,medium-visibility,large-visibility" status="published" border_style="solid" box_shadow="no" box_shadow_blur="0" box_shadow_spread="0" gradient_start_position="0" gradient_end_position="100" gradient_type="linear" radial_direction="center center" linear_angle="180" background_position="center center" background_repeat="no-repeat" fade="no" background_parallax="none" enable_mobile="no" parallax_speed="0.3" background_blend_mode="none" video_aspect_ratio="16:9" video_loop="yes" video_mute="yes" absolute="off" absolute_devices="small,medium,large" sticky="off" sticky_devices="small-visibility,medium-visibility,large-visibility" sticky_transition_offset="0" scroll_offset="0" animation_direction="left" animation_speed="0.3" filter_hue="0" filter_saturation="100" filter_brightness="100" filter_contrast="100" filter_invert="0" filter_sepia="0" filter_opacity="100" filter_blur="0" filter_hue_hover="0" filter_saturation_hover="100" filter_brightness_hover="100" filter_contrast_hover="100" filter_invert_hover="0" filter_sepia_hover="0" filter_opacity_hover="100" filter_blur_hover="0"][fusion_builder_row][fusion_builder_column type="1_3" type="1_3" align_self="auto" content_layout="column" align_content="flex-start" valign_content="flex-start" content_wrap="wrap" spacing="" center_content="no" link="" target="_self" min_height="" hide_on_mobile="small-visibility,medium-visibility,large-visibility" sticky_display="normal,sticky" class="" id="" type_medium="" type_small="" order_medium="0" order_small="0" dimension_spacing_medium="" dimension_spacing_small="" dimension_spacing="" dimension_margin_medium="" dimension_margin_small="" margin_top="" margin_bottom="" padding_medium="" padding_small="" padding_top="" padding_right="" padding_bottom="" padding_left="" hover_type="none" border_sizes="" border_color="" border_style="solid" border_radius="" box_shadow="no" dimension_box_shadow="" box_shadow_blur="0" box_shadow_spread="0" box_shadow_color="" box_shadow_style="" background_type="single" gradient_start_color="" gradient_end_color="" gradient_start_position="0" gradient_end_position="100" gradient_type="linear" radial_direction="center center" linear_angle="180" background_color="" background_image="" background_image_id="" background_position="left top" background_repeat="no-repeat" background_blend_mode="none" render_logics="" filter_type="regular" filter_hue="0" filter_saturation="100" filter_brightness="100" filter_contrast="100" filter_invert="0" filter_sepia="0" filter_opacity="100" filter_blur="0" filter_hue_hover="0" filter_saturation_hover="100" filter_brightness_hover="100" filter_contrast_hover="100" filter_invert_hover="0" filter_sepia_hover="0" filter_opacity_hover="100" filter_blur_hover="0" animation_type="" animation_direction="left" animation_speed="0.3" animation_offset="" last="no" border_position="all"][fusion_imageframe image_id="144|full" max_width="" sticky_max_width="" style_type="" blur="" stylecolor="" hover_type="none" bordersize="" bordercolor="" borderradius="" align_medium="none" align_small="none" align="none" margin_top="" margin_right="" margin_bottom="" margin_left="" lightbox="no" gallery_id="" lightbox_image="" lightbox_image_id="" alt="" link="" linktarget="_self" hide_on_mobile="small-visibility,medium-visibility,large-visibility" sticky_display="normal,sticky" class="" id="" animation_type="" animation_direction="left" animation_speed="0.3" animation_offset="" filter_hue="0" filter_saturation="100" filter_brightness="100" filter_contrast="100" filter_invert="0" filter_sepia="0" filter_opacity="100" filter_blur="0" filter_hue_hover="0" filter_saturation_hover="100" filter_brightness_hover="100" filter_contrast_hover="100" filter_invert_hover="0" filter_sepia_hover="0" filter_opacity_hover="100" From b7d9d8bc28e47568a6da6519922c456696a2ca33 Mon Sep 17 00:00:00 2001 From: selul Date: Thu, 9 Jul 2026 22:31:00 +0300 Subject: [PATCH 05/11] Add Copilot PHPUnit setup workflow --- .github/workflows/copilot-setup-steps.yml | 53 +++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 .github/workflows/copilot-setup-steps.yml diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml new file mode 100644 index 000000000..c6f5e277a --- /dev/null +++ b/.github/workflows/copilot-setup-steps.yml @@ -0,0 +1,53 @@ +name: Copilot Setup Steps + +on: + workflow_dispatch: + push: + paths: + - .github/workflows/copilot-setup-steps.yml + pull_request: + paths: + - .github/workflows/copilot-setup-steps.yml + +jobs: + copilot-setup-steps: + runs-on: ubuntu-22.04 + timeout-minutes: 30 + permissions: + contents: read + services: + database: + image: mysql:latest + env: + MYSQL_DATABASE: wordpress_tests + MYSQL_ROOT_PASSWORD: root + ports: + - 3306:3306 + options: >- + --health-cmd="mysqladmin ping -h 127.0.0.1 -uroot -proot" + --health-interval=10s + --health-timeout=5s + --health-retries=10 + steps: + - name: Checkout source code + uses: actions/checkout@v4 + + - name: Setup PHP version + uses: shivammathur/setup-php@v2 + with: + php-version: '8.0' + extensions: simplexml, mysql + tools: phpunit-polyfills:1.1 + coverage: none + + - name: Install WordPress test suite prerequisites + run: sudo apt-get update && sudo apt-get install -y subversion default-mysql-client + + - name: Install WordPress Test Suite + run: composer install-wp-tests + + - name: Install Composer dependencies + run: composer install --prefer-dist --no-progress --no-suggest + + - name: Verify PHPUnit is available + run: composer phpunit -- --version From f3c2e885ae91b0cd1a2b42373e6c1387608ea059 Mon Sep 17 00:00:00 2001 From: selul Date: Thu, 9 Jul 2026 22:31:00 +0300 Subject: [PATCH 06/11] Add Copilot PHPUnit setup workflow --- .github/workflows/copilot-setup-steps.yml | 53 +++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 .github/workflows/copilot-setup-steps.yml diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml new file mode 100644 index 000000000..c6f5e277a --- /dev/null +++ b/.github/workflows/copilot-setup-steps.yml @@ -0,0 +1,53 @@ +name: Copilot Setup Steps + +on: + workflow_dispatch: + push: + paths: + - .github/workflows/copilot-setup-steps.yml + pull_request: + paths: + - .github/workflows/copilot-setup-steps.yml + +jobs: + copilot-setup-steps: + runs-on: ubuntu-22.04 + timeout-minutes: 30 + permissions: + contents: read + services: + database: + image: mysql:latest + env: + MYSQL_DATABASE: wordpress_tests + MYSQL_ROOT_PASSWORD: root + ports: + - 3306:3306 + options: >- + --health-cmd="mysqladmin ping -h 127.0.0.1 -uroot -proot" + --health-interval=10s + --health-timeout=5s + --health-retries=10 + steps: + - name: Checkout source code + uses: actions/checkout@v4 + + - name: Setup PHP version + uses: shivammathur/setup-php@v2 + with: + php-version: '8.0' + extensions: simplexml, mysql + tools: phpunit-polyfills:1.1 + coverage: none + + - name: Install WordPress test suite prerequisites + run: sudo apt-get update && sudo apt-get install -y subversion default-mysql-client + + - name: Install WordPress Test Suite + run: composer install-wp-tests + + - name: Install Composer dependencies + run: composer install --prefer-dist --no-progress --no-suggest + + - name: Verify PHPUnit is available + run: composer phpunit -- --version From f2ecc078194e980fc9628980f64cb65a2283b65f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 9 Jul 2026 14:00:11 +0000 Subject: [PATCH 07/11] Initial plan From ab34f8c03465ef8b22395da7cca3d72b910c59af Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 9 Jul 2026 19:36:58 +0000 Subject: [PATCH 08/11] fix: invalidate object cache after clearing offload/rollback error meta --- inc/media_offload.php | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/inc/media_offload.php b/inc/media_offload.php index ccef2e03b..613a3f8d1 100644 --- a/inc/media_offload.php +++ b/inc/media_offload.php @@ -2685,12 +2685,31 @@ public static function clear_offload_errors_meta( $meta_key = '' ) { $meta_key = self::META_KEYS['offload_error']; } - return $wpdb->query( + // Collect the affected attachments before the bulk delete so their object + // caches can be invalidated. A raw DELETE bypasses the meta/query caches, + // which would otherwise leave stale WP_Query results for subsequent queries. + $post_ids = $wpdb->get_col( + $wpdb->prepare( + "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = %s", + $meta_key + ) + ); + + $result = $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->postmeta} WHERE meta_key = %s", $meta_key ) ); + + foreach ( $post_ids as $post_id ) { + wp_cache_delete( (int) $post_id, 'post_meta' ); + } + + // Bump the post meta last_changed so cached meta queries are recomputed. + wp_cache_set( 'last_changed', microtime(), 'post_meta' ); + + return $result; } /** From 5806d240308bb0c4fd2ac0f7f8f1c246bb8c749d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 9 Jul 2026 19:45:45 +0000 Subject: [PATCH 09/11] fix: bump posts last_changed after clearing error meta to refresh WP_Query cache --- inc/media_offload.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/inc/media_offload.php b/inc/media_offload.php index 613a3f8d1..999555313 100644 --- a/inc/media_offload.php +++ b/inc/media_offload.php @@ -2706,8 +2706,11 @@ public static function clear_offload_errors_meta( $meta_key = '' ) { wp_cache_delete( (int) $post_id, 'post_meta' ); } - // Bump the post meta last_changed so cached meta queries are recomputed. - wp_cache_set( 'last_changed', microtime(), 'post_meta' ); + // Bump the posts last_changed so cached WP_Query results (which are keyed + // on it) are recomputed on the next query. The raw DELETE above does not + // touch the object cache, so without this the retried rollback/offload + // query could return a stale set that still excludes the cleared posts. + wp_cache_set( 'last_changed', microtime(), 'posts' ); return $result; } From c0df1f3573b795d92bc4fbb0646633b34ba440f9 Mon Sep 17 00:00:00 2001 From: selul Date: Thu, 9 Jul 2026 22:54:15 +0300 Subject: [PATCH 10/11] Fix Copilot setup apt update --- .github/workflows/copilot-setup-steps.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index c6f5e277a..18e61566d 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -41,7 +41,7 @@ jobs: coverage: none - name: Install WordPress test suite prerequisites - run: sudo apt-get update && sudo apt-get install -y subversion default-mysql-client + run: sudo apt-get update --allow-releaseinfo-change && sudo apt-get install -y subversion default-mysql-client - name: Install WordPress Test Suite run: composer install-wp-tests From 23b0967a97ba6cef093fd5d800b43ca79519bc3d Mon Sep 17 00:00:00 2001 From: selul Date: Fri, 10 Jul 2026 13:55:18 +0300 Subject: [PATCH 11/11] Harden E2E runs against shared QA site --- .github/workflows/test-e2e.yml | 24 +++++++++++--- bin/run-e2e-tests.sh | 32 +++++++++++++++++-- playwright.config.ts | 16 ++++++++-- tests/e2e/README.md | 17 ++++------ ...beaver-builder-background-lazyload.spec.ts | 6 ++-- tests/e2e/divi-background-lazyload.spec.ts | 7 ++-- .../e2e/elementor-background-lazyload.spec.ts | 3 +- tests/e2e/gif.spec.ts | 9 +++--- tests/e2e/gutenberg.spec.ts | 10 +++--- .../metaslider-background-lazyload.spec.ts | 6 ++-- tests/e2e/otter-background-lazyload.spec.ts | 5 ++- tests/e2e/ss-pinterest.spec.ts | 24 +++++++------- tests/e2e/thrive-background-lazyload.spec.ts | 7 ++-- 13 files changed, 106 insertions(+), 60 deletions(-) diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml index dd8fce30c..b9c9ba8b9 100755 --- a/.github/workflows/test-e2e.yml +++ b/.github/workflows/test-e2e.yml @@ -4,8 +4,10 @@ on: pull_request: concurrency: - group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.head_ref || github.ref }} - cancel-in-progress: true + # Every run deploys to the same QA host, so separate PRs must not overwrite + # one another while their E2E suites are running. + group: e2e-testing-optimole-qa + cancel-in-progress: false jobs: e2e: @@ -16,13 +18,13 @@ jobs: - name: Setup Node uses: actions/setup-node@v4 with: - node-version: 16 + node-version: 22 cache: 'npm' - name: Install dependencies run: | npm ci composer install --no-dev --prefer-dist --no-progress --no-suggest - npx playwright install + npx playwright install --with-deps chromium firefox - name: Make build run: | npm run build @@ -39,9 +41,21 @@ jobs: run: ./bin/run-e2e-tests.sh - name: Run E2E tests id: run_tests + env: + E2E_BASE_URL: http://testing.optimole.com run: | npm run e2e:run continue-on-error: false - name: Check test results if: steps.run_tests.outcome != 'success' - run: exit 1 \ No newline at end of file + run: exit 1 + - name: Upload Playwright artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: playwright-artifacts + path: | + test-results/ + playwright-report/ + if-no-files-found: warn + retention-days: 7 diff --git a/bin/run-e2e-tests.sh b/bin/run-e2e-tests.sh index c5d84b9ad..201082ccc 100755 --- a/bin/run-e2e-tests.sh +++ b/bin/run-e2e-tests.sh @@ -1,8 +1,34 @@ #!/usr/bin/env bash +set -euo pipefail + +readonly DB_RETRY_DELAY=5 +readonly DB_MAX_ATTEMPTS=24 +readonly QA_URL='http://testing.optimole.com' + eval "$(ssh-agent -s)" -mkdir $HOME/.ssh -echo "$SSH_KEY" > "$HOME/.ssh/key" +install -d -m 700 "$HOME/.ssh" +printf '%s\n' "$SSH_KEY" > "$HOME/.ssh/key" chmod 600 "$HOME/.ssh/key" rsync --delete -rc --force --exclude-from="$GITHUB_WORKSPACE/.distignore" -e "ssh -i $HOME/.ssh/key -o StrictHostKeyChecking=no -p $SSH_PORT" "$GITHUB_WORKSPACE/dist/optimole-wp/" $SSH_USERNAME@$SSH_HOST:$SSH_PATH -ssh -i $HOME/.ssh/key -o StrictHostKeyChecking=no -p $SSH_PORT $SSH_USERNAME@$SSH_HOST "cd /var/www && docker-compose run --rm cli wp elementor flush_css --url=http://testing.optimole.com" +ssh -i "$HOME/.ssh/key" -o StrictHostKeyChecking=no -p "$SSH_PORT" "$SSH_USERNAME@$SSH_HOST" "DB_RETRY_DELAY='$DB_RETRY_DELAY' DB_MAX_ATTEMPTS='$DB_MAX_ATTEMPTS' QA_URL='$QA_URL' bash -s" <<'REMOTE_COMMAND' +set -euo pipefail + +cd /var/www +docker-compose up -d db wordpress + +attempt=1 +until docker-compose run --rm cli wp db check --url="$QA_URL"; do + + if [ "$attempt" -ge "$DB_MAX_ATTEMPTS" ]; then + echo "The QA database did not become ready after $DB_MAX_ATTEMPTS attempts." >&2 + exit 1 + fi + + echo "QA database is not ready (attempt $attempt/$DB_MAX_ATTEMPTS); retrying in $DB_RETRY_DELAY seconds..." >&2 + attempt=$((attempt + 1)) + sleep "$DB_RETRY_DELAY" +done + +docker-compose run --rm cli wp elementor flush_css --url="$QA_URL" +REMOTE_COMMAND diff --git a/playwright.config.ts b/playwright.config.ts index f82cb686a..1ac207862 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -3,12 +3,22 @@ import { defineConfig } from '@playwright/test'; export default defineConfig({ testDir: './tests/e2e', timeout: 60000, - workers: process.env.CI ? 2 : 5, + // The CI suite targets one shared QA site, so concurrent browser sessions + // must not race over its lazy-loading state or third-party integrations. + workers: process.env.CI ? 1 : 5, retries: process.env.CI ? 2 : 0, + reporter: process.env.CI + ? [['line'], ['html', { open: 'never', outputFolder: 'playwright-report' }]] + : 'list', use: { - baseURL: 'http://testing.optimole.com', // Replace with your local WordPress URL + // CI supplies the shared QA URL. Local runs are deliberately local by + // default to avoid exercising the mutable QA site accidentally. + baseURL: process.env.E2E_BASE_URL || 'http://localhost', trace: 'on-first-retry', navigationTimeout: 45000, + screenshot: 'only-on-failure', + video: 'retain-on-failure', + viewport: { width: 1280, height: 720 }, }, projects: [ { @@ -20,4 +30,4 @@ export default defineConfig({ use: { browserName: 'firefox' }, }, ] -}); \ No newline at end of file +}); diff --git a/tests/e2e/README.md b/tests/e2e/README.md index 53b8ab97c..6729d2957 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -1,24 +1,21 @@ > [!IMPORTANT] -> Tests run on the testing.optimole.com WordPress instance, so running in a local environment might not reflect the actual results. +> CI deploys to and tests a shared QA instance. Local runs default to `http://localhost`; set `E2E_BASE_URL` to your local WordPress URL before running the suite. ### To run e2e tests locally: In the root directory of the project, run: ```bash -npm install --frozen-lockfile +npm ci npx playwright install -npm run e2e:open +E2E_BASE_URL=http://localhost:8080 npm run e2e:open ``` ```bash #You can run the tests in headless mode by running: -npm run e2e:run +E2E_BASE_URL=http://localhost:8080 npm run e2e:run -# If you want to run individual tests, you can do so by running: -npm run e2e:test tests/e2e/ - -# You can directly run the tests using playwright by running: -npx playwright test tests/e2e/ +# Run an individual test file: +E2E_BASE_URL=http://localhost:8080 npx playwright test tests/e2e/.spec.ts ``` @@ -26,5 +23,3 @@ npx playwright test tests/e2e/ - - diff --git a/tests/e2e/beaver-builder-background-lazyload.spec.ts b/tests/e2e/beaver-builder-background-lazyload.spec.ts index fc9dabdb2..3431c87b2 100644 --- a/tests/e2e/beaver-builder-background-lazyload.spec.ts +++ b/tests/e2e/beaver-builder-background-lazyload.spec.ts @@ -21,12 +21,12 @@ test.describe('Check Homepage', () => { }); test('After scroll the background images should be loaded', async ({ page }) => { - await page.evaluate(() => window.scrollTo(0, 2500)); - const column = page.locator('.entry-content .fl-col-content').nth(4); + await column.scrollIntoViewIfNeeded(); await expect(column).toHaveClass(/optml-bg-lazyloaded/); const row = page.locator('.entry-content .fl-row-bg-photo > .fl-row-content-wrap').nth(1); + await row.scrollIntoViewIfNeeded(); await expect(row).toHaveClass(/optml-bg-lazyloaded/); }); -}); \ No newline at end of file +}); diff --git a/tests/e2e/divi-background-lazyload.spec.ts b/tests/e2e/divi-background-lazyload.spec.ts index f631400d3..9fdbc9e7b 100644 --- a/tests/e2e/divi-background-lazyload.spec.ts +++ b/tests/e2e/divi-background-lazyload.spec.ts @@ -27,15 +27,16 @@ test.describe('Check Divi Background Page', () => { }); test('After scroll backgrounds should be loaded', async ({ page }) => { - await page.evaluate(() => window.scrollTo(0, 4250)); - const slide = page.locator('.entry-content .et_pb_slides > .et_pb_slide_3').first(); + await slide.scrollIntoViewIfNeeded(); await expect(slide).toHaveClass(/optml-bg-lazyloaded/); const module = page.locator('.entry-content .et_pb_module').nth(4); + await module.scrollIntoViewIfNeeded(); await expect(module).toHaveClass(/optml-bg-lazyloaded/); const row = page.locator('.entry-content .et_pb_row_3').first(); + await row.scrollIntoViewIfNeeded(); await expect(row).toHaveClass(/optml-bg-lazyloaded/); }); -}); \ No newline at end of file +}); diff --git a/tests/e2e/elementor-background-lazyload.spec.ts b/tests/e2e/elementor-background-lazyload.spec.ts index 4aa02fa6a..10ea1c519 100644 --- a/tests/e2e/elementor-background-lazyload.spec.ts +++ b/tests/e2e/elementor-background-lazyload.spec.ts @@ -42,7 +42,8 @@ test.describe('Check Elementor Background Page', () => { for (let i = 6; i < 12; i++) { const item = page.locator('.elementor-inner .elementor-background-slideshow__slide__image').nth(i); + await item.scrollIntoViewIfNeeded(); await expect(item).toHaveClass(/optml-bg-lazyloaded/); } }); -}); \ No newline at end of file +}); diff --git a/tests/e2e/gif.spec.ts b/tests/e2e/gif.spec.ts index be034a7a0..0fb0fc1a2 100644 --- a/tests/e2e/gif.spec.ts +++ b/tests/e2e/gif.spec.ts @@ -27,9 +27,10 @@ test.describe('Check gif page', () => { test('Images with gifs has proper tags', async ({ page }) => { // Skip if browser has native lazy loading - if (typeof HTMLImageElement !== 'undefined' && 'loading' in HTMLImageElement.prototype) { - test.skip(); - } + test.skip( + await page.evaluate(() => 'loading' in HTMLImageElement.prototype), + 'Native lazy loading does not use Optimole placeholder attributes.' + ); const images = await page.locator('.wp-block-image img').all(); for (const image of images) { @@ -38,4 +39,4 @@ test.describe('Check gif page', () => { await expect(image).toHaveAttribute('data-opt-src', /i\.optimole\.com/); } }); -}); \ No newline at end of file +}); diff --git a/tests/e2e/gutenberg.spec.ts b/tests/e2e/gutenberg.spec.ts index 6e34e5a0b..bc6cb6254 100644 --- a/tests/e2e/gutenberg.spec.ts +++ b/tests/e2e/gutenberg.spec.ts @@ -11,9 +11,11 @@ test.describe('Check gutenberg page', () => { }); test('Gutenberg images should have data-opt-src attribute', async ({ page }) => { - if (typeof HTMLImageElement !== 'undefined' && 'loading' in HTMLImageElement.prototype) { - test.skip(); - } + test.skip( + await page.evaluate(() => 'loading' in HTMLImageElement.prototype), + 'Native lazy loading does not use Optimole placeholder attributes.' + ); + const image = await page.locator('.wp-block-media-text__media > img'); await expect(image).toHaveAttribute('data-opt-src', /i\.optimole\.com/); }); @@ -29,4 +31,4 @@ test.describe('Check gutenberg page', () => { await cover.scrollIntoViewIfNeeded(); await expect(cover).toHaveCSS('background-image', /i\.optimole\.com/); }); -}); \ No newline at end of file +}); diff --git a/tests/e2e/metaslider-background-lazyload.spec.ts b/tests/e2e/metaslider-background-lazyload.spec.ts index edcdfe550..1c607a0cc 100644 --- a/tests/e2e/metaslider-background-lazyload.spec.ts +++ b/tests/e2e/metaslider-background-lazyload.spec.ts @@ -15,9 +15,7 @@ test.describe('Check Metaslider Background Page', () => { } }); - test('After scroll slider background images not in view should have no background', async ({ page }) => { - await page.evaluate(() => window.scrollTo(0, 100)); - + test('Slider background images outside the initial viewport should have no background', async ({ page }) => { const slider = page.locator('.entry-content .coin-slider > .coin-slider').nth(1); await expect(slider).toHaveCSS('background-image', /none/); @@ -26,4 +24,4 @@ test.describe('Check Metaslider Background Page', () => { await expect(slide).toHaveCSS('background-image', /none/); } }); -}); \ No newline at end of file +}); diff --git a/tests/e2e/otter-background-lazyload.spec.ts b/tests/e2e/otter-background-lazyload.spec.ts index e78a792ec..124e2e2d4 100644 --- a/tests/e2e/otter-background-lazyload.spec.ts +++ b/tests/e2e/otter-background-lazyload.spec.ts @@ -18,9 +18,8 @@ test.describe('Check Otter Background Lazyload', () => { }); test('Otter Section Block should have background lazyloaded', async ({ page }) => { - await page.evaluate(() => window.scrollTo(0, 500)); - const section = page.locator('#wp-block-themeisle-blocks-advanced-columns-e62611eb').first(); + await section.scrollIntoViewIfNeeded(); await expect(section).toHaveClass(/optml-bg-lazyloaded/); await expect(section).toHaveCSS('background-image', /url\(.*\.i\.optimole\.com.*\)/); @@ -28,4 +27,4 @@ test.describe('Check Otter Background Lazyload', () => { await expect(overlay).toHaveClass(/optml-bg-lazyloaded/); await expect(overlay).toHaveCSS('background-image', /url\(.*\.i\.optimole\.com.*\)/); }); -}); \ No newline at end of file +}); diff --git a/tests/e2e/ss-pinterest.spec.ts b/tests/e2e/ss-pinterest.spec.ts index a9d8f3eea..4fea73d78 100644 --- a/tests/e2e/ss-pinterest.spec.ts +++ b/tests/e2e/ss-pinterest.spec.ts @@ -5,22 +5,20 @@ test.describe('Sassy Social Share', () => { await page.goto('/sassy-social-share/'); }); - test('click on button', async ({ page }) => { - const buttons = await page.locator('.heateorSssSharing.heateorSssPinterestBackground').all(); - for (const button of buttons) { - await button.click({ force: true }); - } + test('Pinterest share controls are available', async ({ page }) => { + const buttons = page.locator('.heateorSssSharing.heateorSssPinterestBackground'); + await expect(buttons).not.toHaveCount(0); + await expect(buttons.first()).toBeVisible(); }); test('images should not have quality:eco', async ({ page }) => { - await page.evaluate(() => window.scrollTo(0, 2500)); - - const images = await page.locator('img').all(); - expect(images.length).toBe(5); - await page.waitForTimeout(2000); + const images = page.locator('img'); + await expect(images).toHaveCount(5); + for (let i = 0; i < 4; i++) { - const src = await images[i].getAttribute('src'); - expect(src).not.toMatch(/eco/); + const image = images.nth(i); + await image.scrollIntoViewIfNeeded(); + await expect(image).toHaveAttribute('src', /^(?!.*eco).+$/); } }); -}); \ No newline at end of file +}); diff --git a/tests/e2e/thrive-background-lazyload.spec.ts b/tests/e2e/thrive-background-lazyload.spec.ts index 65eea3f73..1f26be456 100644 --- a/tests/e2e/thrive-background-lazyload.spec.ts +++ b/tests/e2e/thrive-background-lazyload.spec.ts @@ -27,15 +27,16 @@ test.describe('Check Thrive Background Page', () => { }); test('After scroll backgrounds should be loaded', async ({ page }) => { - await page.evaluate(() => window.scrollTo(0, 3000)); - const box = page.locator('.entry-content .tve-content-box-background').nth(1); + await box.scrollIntoViewIfNeeded(); await expect(box).toHaveClass(/optml-bg-lazyloaded/); const section = page.locator('.entry-content .tve-page-section-out').nth(1); + await section.scrollIntoViewIfNeeded(); await expect(section).toHaveClass(/optml-bg-lazyloaded/); const text = page.locator('.entry-content .thrv_text_element').nth(2); + await text.scrollIntoViewIfNeeded(); await expect(text).toHaveClass(/optml-bg-lazyloaded/); }); -}); \ No newline at end of file +});