Commit 2da4be9d5c7 for woocommerce
commit 2da4be9d5c72b6366e8e2bc4052fc5aa1b980c63
Author: AnnaMag <AnnaMag@users.noreply.github.com>
Date: Fri Nov 6 22:16:52 2026 +0100
Agent readiness: background scan, stored report and REST endpoint (#70342)
* Agent readiness: product and store checks behind a feature flag
Part of #70328.
* Agent readiness: ask each variation for its own GTIN
missing_gtin passed a variable product as soon as one variation had a
GTIN, although channels list each variation as its own item and the UCP
catalog sends each variation's own GTIN. It now fails when any published
variation that needs one has no GTIN of its own; the variable parent needs
none. The README no longer says UCP sends no GTINs.
* Agent readiness: background scan, stored report and REST endpoint
Part of #70342.
* Agent readiness: never run the scan in a page or REST request
A queued scan that stalls now stays queued instead of running the whole
catalog pass in the next "Run check" or POST request, where a large store
could run out of time or slow the site. The tab keeps Run check disabled
and says scheduled actions aren't running, and the REST response adds
scan_stalled so other clients can say the same. Splitting the product pass
into batched actions is WOOAI-1661.
* Agent readiness: queue one scan when requests race, count variation images
Schedule the requested scan as a unique action, so two requests that both
find nothing pending still queue one scan. A variable product with no main
image no longer fails missing_images when a published variation has its own
image. The changelog entry is a patch, as its Comment-only form requires.
* Agent readiness: count a running daily scan as pending, size-check the variation image
A running daily scan now counts as pending, so Run check doesn't queue a
second scan behind it. A variable product with no image of its own is
listed with its first published variation's image, so the size check reads
that image too. The variation image guidance covers a product with no image,
and the scan primes the variations' images in one query.
* Agent readiness: stop counting an abandoned daily scan as pending
A daily scan killed mid-run stays marked running until Action Scheduler's
cleanup marks it failed. Count it as pending only for RUNNING_FOR (10
minutes) after it started, so Run check is freed even when that cleanup
does not run.
* Agent readiness: check every variation image for size
The image size check read only the first variation's image, so the result
depended on variation order. Check every image the product lists with: its
own, or each published variation's (its own, else the parent's). Any one
under 500 x 500 fails the product.
diff --git a/plugins/woocommerce/changelog/add-agent-readiness-background-scan b/plugins/woocommerce/changelog/add-agent-readiness-background-scan
new file mode 100644
index 00000000000..ae98e525698
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-agent-readiness-background-scan
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+Comment: Experimental agent readiness scan runs in the background with a REST endpoint; behind a hidden flag, no merchant change.
+
diff --git a/plugins/woocommerce/includes/class-woocommerce.php b/plugins/woocommerce/includes/class-woocommerce.php
index d65f707e473..7c3650600d8 100644
--- a/plugins/woocommerce/includes/class-woocommerce.php
+++ b/plugins/woocommerce/includes/class-woocommerce.php
@@ -456,6 +456,8 @@ final class WooCommerce {
$container->get( Automattic\WooCommerce\Internal\OrderWithdrawal\OrderWithdrawalController::class )->register();
$container->get( Automattic\WooCommerce\Internal\Ucp\UcpDiscovery::class )->register();
$container->get( Automattic\WooCommerce\Internal\AgentReadiness\StatusTab::class )->register();
+ $container->get( Automattic\WooCommerce\Internal\AgentReadiness\ScheduledScan::class )->register();
+ $container->get( Automattic\WooCommerce\Internal\AgentReadiness\ReadinessRestController::class )->register();
$container->get( Automattic\WooCommerce\Internal\Admin\OrderTaxLookupMigrator::class )->register();
$container->get( Automattic\WooCommerce\Internal\ProductDownloads\DownloadableFileAttachments::class )->register();
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/AgentReadiness.php b/plugins/woocommerce/src/Internal/AgentReadiness/AgentReadiness.php
index e0f376ae09c..c8382992910 100644
--- a/plugins/woocommerce/src/Internal/AgentReadiness/AgentReadiness.php
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/AgentReadiness.php
@@ -26,9 +26,9 @@ class AgentReadiness {
public const PRODUCT_IDS_LIMIT = 10;
/**
- * Transient holding the last report.
+ * Option holding the last report, kept until the next scan replaces it.
*/
- public const REPORT_TRANSIENT = 'woocommerce_agent_readiness_report';
+ public const REPORT_OPTION = 'woocommerce_agent_readiness_report';
/**
* Store-wide conditions.
@@ -200,18 +200,18 @@ class AgentReadiness {
* @return array<string, mixed>|null
*/
public function get_cached_report(): ?array {
- $report = get_transient( self::REPORT_TRANSIENT );
+ $report = get_option( self::REPORT_OPTION );
return is_array( $report ) ? $report : null;
}
/**
- * Run the report and store it for an hour.
+ * Run the report and store it. Not autoloaded: only the readiness screens read it.
*
* @return array<string, mixed>
*/
public function refresh_report(): array {
$report = $this->get_report();
- set_transient( self::REPORT_TRANSIENT, $report, HOUR_IN_SECONDS );
+ update_option( self::REPORT_OPTION, $report, false );
return $report;
}
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/Checks/ImageTooSmallCheck.php b/plugins/woocommerce/src/Internal/AgentReadiness/Checks/ImageTooSmallCheck.php
index 84005fdb204..ac93e6c1d56 100644
--- a/plugins/woocommerce/src/Internal/AgentReadiness/Checks/ImageTooSmallCheck.php
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/Checks/ImageTooSmallCheck.php
@@ -81,20 +81,20 @@ class ImageTooSmallCheck extends AbstractReadinessCheck {
}
/**
- * Whether the product has this problem.
+ * Whether the product has this problem: any image it lists with, for a variable product any
+ * variation's, is too small. Images without a recorded size are not judged.
*
* @param \WC_Product $product Published product.
* @param ProductScan $scan Catalog-wide lookups.
*/
public function fails( \WC_Product $product, ProductScan $scan ): bool {
- $image_id = ProductFields::image_id( $product );
- if ( 0 === $image_id ) {
- return false;
+ foreach ( ProductFields::listing_image_ids( $product ) as $image_id ) {
+ $metadata = wp_get_attachment_metadata( $image_id );
+ if ( is_array( $metadata ) && ! empty( $metadata['width'] ) && ! empty( $metadata['height'] )
+ && ( (int) $metadata['width'] < self::MIN_PIXELS || (int) $metadata['height'] < self::MIN_PIXELS ) ) {
+ return true;
+ }
}
- $metadata = wp_get_attachment_metadata( $image_id );
- if ( ! is_array( $metadata ) || empty( $metadata['width'] ) || empty( $metadata['height'] ) ) {
- return false;
- }
- return (int) $metadata['width'] < self::MIN_PIXELS || (int) $metadata['height'] < self::MIN_PIXELS;
+ return false;
}
}
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/Checks/SimpleCheck.php b/plugins/woocommerce/src/Internal/AgentReadiness/Checks/SimpleCheck.php
index 05ab5c77e77..8539036c30a 100644
--- a/plugins/woocommerce/src/Internal/AgentReadiness/Checks/SimpleCheck.php
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/Checks/SimpleCheck.php
@@ -77,7 +77,8 @@ final class SimpleCheck extends AbstractReadinessCheck {
'fix' => __( 'Add a main product image.', 'woocommerce' ),
),
self::fields( array( 'image_url', 'image_link', 'imageLink' ), self::EFFECT_NOT_LISTABLE ),
- static fn( \WC_Product $product ): bool => 0 === ProductFields::image_id( $product )
+ // Each variation lists as its own item with its own image, so one image on a variation is enough.
+ static fn( \WC_Product $product ): bool => array() === ProductFields::listing_image_ids( $product )
),
new self(
'missing_product_text',
@@ -124,7 +125,7 @@ final class SimpleCheck extends AbstractReadinessCheck {
'missing_variation_images',
array(
'label' => __( 'Variable products with variations missing images', 'woocommerce' ),
- 'why' => __( 'Each variation lists as its own item; without its own image it shows the parent image, which may not match the color or finish.', 'woocommerce' ),
+ 'why' => __( 'Each variation lists as its own item; without its own image it shows the parent image, which may not match the color or finish, or no image when the product has none.', 'woocommerce' ),
'fix' => __( 'Add an image to each variation.', 'woocommerce' ),
),
self::fields( array( 'image_url', 'image_link', 'imageLink' ), self::EFFECT_GAPS ),
@@ -148,7 +149,7 @@ final class SimpleCheck extends AbstractReadinessCheck {
static fn( \WC_Product $product ): bool => mb_strlen( $product->get_name() ) > self::TITLE_MAX_LENGTH
),
);
- $by_id = array();
+ $by_id = array();
foreach ( $checks as $check ) {
$by_id[ $check->id() ] = $check;
}
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/ProductFields.php b/plugins/woocommerce/src/Internal/AgentReadiness/ProductFields.php
index 2122e59c5ee..2e9ba32c056 100644
--- a/plugins/woocommerce/src/Internal/AgentReadiness/ProductFields.php
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/ProductFields.php
@@ -233,6 +233,22 @@ class ProductFields {
return array_values( array_unique( array_map( 'strval', $values ) ) );
}
+ /**
+ * The images a channel lists the product with: its own, or, for a variable product, each
+ * published variation's image (its own, else the parent's), since every variation lists as
+ * an item. Empty when there is none.
+ *
+ * @param \WC_Product $product Product.
+ * @return int[]
+ */
+ public static function listing_image_ids( \WC_Product $product ): array {
+ $variations = self::variations( $product );
+ if ( array() === $variations ) {
+ $variations = array( $product );
+ }
+ return array_values( array_filter( array_unique( array_map( array( self::class, 'image_id' ), $variations ) ) ) );
+ }
+
/**
* The featured image's attachment id, or 0 when it is unset or the
* attachment no longer exists.
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/ProductScan.php b/plugins/woocommerce/src/Internal/AgentReadiness/ProductScan.php
index f4e9ace1299..ac22fc9f6e4 100644
--- a/plugins/woocommerce/src/Internal/AgentReadiness/ProductScan.php
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/ProductScan.php
@@ -264,16 +264,28 @@ class ProductScan {
false,
true
);
- $placeholders = implode( ',', array_fill( 0, count( $product_ids ), '%d' ) );
- _prime_post_caches(
+ $placeholders = implode( ',', array_fill( 0, count( $product_ids ), '%d' ) );
+ $variation_ids = array_map(
+ 'intval',
$wpdb->get_col(
$wpdb->prepare(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- One %d placeholder per product.
"SELECT ID FROM {$wpdb->posts} WHERE post_type = 'product_variation' AND post_parent IN ({$placeholders})",
array_map( 'intval', $product_ids )
)
+ )
+ );
+ _prime_post_caches( $variation_ids, true, true );
+
+ // The variations' own images, which a variable product with no image of its own is listed with.
+ _prime_post_caches(
+ array_filter(
+ array_map(
+ static fn( int $variation_id ): int => (int) get_post_meta( $variation_id, '_thumbnail_id', true ),
+ $variation_ids
+ )
),
- true,
+ false,
true
);
}
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/README.md b/plugins/woocommerce/src/Internal/AgentReadiness/README.md
index 5f88fa62c1d..d65e71cd25b 100644
--- a/plugins/woocommerce/src/Internal/AgentReadiness/README.md
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/README.md
@@ -96,9 +96,17 @@ array(
`AgentReadiness::check_product( $product )` judges one product as the scan does: each failing check with its field and effect per channel for that product (after per-product exceptions such as a partly priced variable product), and the product's status per channel (`not_listable`, `with_gaps`, `complete`). It returns null for products the scan leaves out, and judges a variation by its parent. Extensions that rank or fix products one at a time use it, since the stored report keeps only counts and up to 10 example ids per check.
+## Background scan and REST
+
+"Run check" on the tab, or `POST wc/v3/agent-readiness/scan`, queues the scan in Action Scheduler (`ScheduledScan`). The report is stored in the `woocommerce_agent_readiness_report` option (not autoloaded). Each run schedules the next one a day later, before scanning, so a failed scan doesn't stop the daily refresh; a run that finds the feature disabled does. If a queued scan still hasn't started 10 minutes after it was due and no other scheduled action has finished in that time (Action Scheduler isn't running), the tab says so and the scan stays queued. The scan never runs in a page or REST request; splitting the product pass into one action per batch is WOOAI-1661.
+
+Product changes don't update the stored report: a fixed product leaves it at the next daily scan or "Run check". On a store with 11,000 products, a scan (stopping at 5,000) took about 4 seconds and 144 MB.
+
+`GET wc/v3/agent-readiness` returns `{ "report": <report or null>, "scan_pending": <bool>, "scan_stalled": <bool> }`; `scan_stalled` is true while a queued scan waits on a stalled Action Scheduler. Both routes need `manage_woocommerce` and exist only while the feature is enabled.
+
The same shape is meant for every consumer: this Status tab, an outside scan of a store, and extensions that rank the findings by sales or offer fixes. Check and channel ids are stable keys.
-The scan walks published simple and variable products shown in the shop catalog in batches of 200, most recently modified first, primes meta, terms, images and variations per batch, and stops at 5,000 products. The Status tab runs it on request and keeps the result in a transient for an hour.
+The scan walks published simple and variable products shown in the shop catalog in batches of 200, most recently modified first, primes meta, terms, images and variations per batch, and stops at 5,000 products.
## Updating rules
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/ReadinessRestController.php b/plugins/woocommerce/src/Internal/AgentReadiness/ReadinessRestController.php
new file mode 100644
index 00000000000..e3c950715bc
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/ReadinessRestController.php
@@ -0,0 +1,158 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\AgentReadiness;
+
+use Automattic\WooCommerce\Internal\Features\FeaturesController;
+use Automattic\WooCommerce\Internal\RestApiControllerBase;
+use WP_REST_Request;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * GET wc/v3/agent-readiness returns the stored report; POST
+ * wc/v3/agent-readiness/scan queues a new scan. Both answer with the report
+ * (null before the first scan) and whether a scan is pending, so a settings
+ * page can show the last result and poll while a scan runs.
+ *
+ * Registered only while the agent_readiness feature is enabled.
+ *
+ * @since 11.4.0
+ */
+class ReadinessRestController extends RestApiControllerBase {
+
+ /**
+ * Features controller.
+ *
+ * @var FeaturesController
+ */
+ private FeaturesController $features_controller;
+
+ /**
+ * Readiness service.
+ *
+ * @var AgentReadiness
+ */
+ private AgentReadiness $agent_readiness;
+
+ /**
+ * Background scan.
+ *
+ * @var ScheduledScan
+ */
+ private ScheduledScan $scheduled_scan;
+
+ /**
+ * Initialize dependencies.
+ *
+ * @internal
+ *
+ * @param FeaturesController $features_controller Features controller.
+ * @param AgentReadiness $agent_readiness Readiness service.
+ * @param ScheduledScan $scheduled_scan Background scan.
+ */
+ final public function init( FeaturesController $features_controller, AgentReadiness $agent_readiness, ScheduledScan $scheduled_scan ): void {
+ $this->features_controller = $features_controller;
+ $this->agent_readiness = $agent_readiness;
+ $this->scheduled_scan = $scheduled_scan;
+ }
+
+ /**
+ * Key in the WooCommerce REST namespaces list.
+ */
+ protected function get_rest_api_namespace(): string {
+ return 'agent-readiness';
+ }
+
+ /**
+ * Register the routes.
+ */
+ public function register_routes(): void {
+ if ( ! $this->features_controller->feature_is_enabled( StatusTab::FEATURE_ID ) ) {
+ return;
+ }
+ register_rest_route(
+ $this->route_namespace,
+ '/agent-readiness',
+ array(
+ array(
+ 'methods' => \WP_REST_Server::READABLE,
+ 'callback' => fn( $request ) => $this->run( $request, 'get_status' ),
+ 'permission_callback' => fn( $request ) => $this->check_permission( $request, 'manage_woocommerce' ),
+ ),
+ 'schema' => array( $this, 'get_status_schema' ),
+ )
+ );
+ register_rest_route(
+ $this->route_namespace,
+ '/agent-readiness/scan',
+ array(
+ array(
+ 'methods' => \WP_REST_Server::CREATABLE,
+ 'callback' => fn( $request ) => $this->run( $request, 'queue_scan' ),
+ 'permission_callback' => fn( $request ) => $this->check_permission( $request, 'manage_woocommerce' ),
+ ),
+ 'schema' => array( $this, 'get_status_schema' ),
+ )
+ );
+ }
+
+ /**
+ * The stored report, whether a scan is pending, and whether it has stalled.
+ *
+ * @param WP_REST_Request $request Request.
+ * @phpstan-param WP_REST_Request<array<string, mixed>> $request
+ * @return array{report:array<string,mixed>|null,scan_pending:bool,scan_stalled:bool}
+ */
+ protected function get_status( WP_REST_Request $request ): array {
+ $pending = $this->scheduled_scan->is_pending();
+ return array(
+ 'report' => $this->agent_readiness->get_cached_report(),
+ 'scan_pending' => $pending,
+ 'scan_stalled' => $pending && $this->scheduled_scan->is_stalled(),
+ );
+ }
+
+ /**
+ * Queue a scan, then answer like GET.
+ *
+ * @param WP_REST_Request $request Request.
+ * @phpstan-param WP_REST_Request<array<string, mixed>> $request
+ * @return \WP_REST_Response
+ */
+ protected function queue_scan( WP_REST_Request $request ): \WP_REST_Response {
+ $this->scheduled_scan->queue();
+ return new \WP_REST_Response( $this->get_status( $request ), 202 );
+ }
+
+ /**
+ * Response schema. The report's shape is documented in this directory's README.
+ *
+ * @internal
+ */
+ public function get_status_schema(): array {
+ $schema = $this->get_base_schema();
+ $schema['title'] = 'agent_readiness';
+ $schema['properties'] = array(
+ 'report' => array(
+ 'description' => __( 'The last stored readiness report, or null before the first scan.', 'woocommerce' ),
+ 'type' => array( 'object', 'null' ),
+ 'context' => array( 'view' ),
+ 'readonly' => true,
+ ),
+ 'scan_pending' => array(
+ 'description' => __( 'Whether a requested scan has not finished yet.', 'woocommerce' ),
+ 'type' => 'boolean',
+ 'context' => array( 'view' ),
+ 'readonly' => true,
+ ),
+ 'scan_stalled' => array(
+ 'description' => __( 'Whether the requested scan has not started because scheduled actions are not running. It stays queued and runs once they are.', 'woocommerce' ),
+ 'type' => 'boolean',
+ 'context' => array( 'view' ),
+ 'readonly' => true,
+ ),
+ );
+ return $schema;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/ScheduledScan.php b/plugins/woocommerce/src/Internal/AgentReadiness/ScheduledScan.php
new file mode 100644
index 00000000000..b93c086d6cd
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/ScheduledScan.php
@@ -0,0 +1,156 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\AgentReadiness;
+
+use Automattic\WooCommerce\Internal\Features\FeaturesController;
+use Automattic\WooCommerce\Internal\RegisterHooksInterface;
+use Automattic\WooCommerce\Internal\Utilities\ActionSchedulerUtil;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Runs the readiness scan in Action Scheduler instead of the page request,
+ * and keeps the stored report fresh with one run a day.
+ *
+ * Each run schedules the next one a day later, so nothing is queued until a
+ * merchant or the REST API asks for the first scan, and no request pays for a
+ * scheduling check. The next run is scheduled before the scan, so a scan that
+ * fails or times out does not end the chain; a run that finds the feature
+ * disabled does.
+ *
+ * A requested scan that has not started 10 minutes after it was due, while no
+ * other scheduled action has finished either, is taken as stalled (Action
+ * Scheduler is not running on the site). It stays queued: the scan never runs
+ * in a page or REST request, where a large catalog could run out of time or
+ * slow the site. Splitting the product pass into one action per batch is
+ * WOOAI-1661.
+ *
+ * @since 11.4.0
+ */
+class ScheduledScan implements RegisterHooksInterface {
+
+ public const RUN_NOW_HOOK = 'woocommerce_agent_readiness_scan';
+
+ public const DAILY_HOOK = 'woocommerce_agent_readiness_daily_scan';
+
+ public const GROUP = 'woocommerce-agent-readiness';
+
+ public const STALLED_AFTER = 10 * MINUTE_IN_SECONDS;
+
+ /**
+ * How long a daily scan marked running still counts as pending. A process killed mid-scan
+ * leaves it running until Action Scheduler's cleanup marks it failed (after 5 minutes, on a
+ * queue run), so this frees Run check even when that cleanup does not run.
+ */
+ public const RUNNING_FOR = 10 * MINUTE_IN_SECONDS;
+
+ /**
+ * Features controller.
+ *
+ * @var FeaturesController
+ */
+ private FeaturesController $features_controller;
+
+ /**
+ * Readiness service.
+ *
+ * @var AgentReadiness
+ */
+ private AgentReadiness $agent_readiness;
+
+ /**
+ * Initialize dependencies.
+ *
+ * @internal
+ *
+ * @param FeaturesController $features_controller Features controller.
+ * @param AgentReadiness $agent_readiness Readiness service.
+ */
+ final public function init( FeaturesController $features_controller, AgentReadiness $agent_readiness ): void {
+ $this->features_controller = $features_controller;
+ $this->agent_readiness = $agent_readiness;
+ }
+
+ /**
+ * Register the scan actions.
+ */
+ public function register(): void {
+ add_action( self::RUN_NOW_HOOK, array( $this, 'run' ) );
+ add_action( self::DAILY_HOOK, array( $this, 'run' ) );
+ }
+
+ /**
+ * Queue a scan unless one is already waiting or running.
+ */
+ public function queue(): void {
+ if ( ! $this->is_pending() ) {
+ // A single action due now rather than an async one, which has no date to tell a stalled scan by.
+ // Unique, so two requests that both found nothing pending still queue one scan.
+ as_schedule_single_action( time(), self::RUN_NOW_HOOK, array(), self::GROUP, true );
+ }
+ }
+
+ /**
+ * Whether a requested scan has not finished yet, or the daily scan started running in the
+ * last RUNNING_FOR seconds. The next daily scan, still waiting for its date, doesn't count.
+ */
+ public function is_pending(): bool {
+ if ( ActionSchedulerUtil::has_scheduled_action( self::RUN_NOW_HOOK, array(), self::GROUP ) ) {
+ return true;
+ }
+ $running_daily = as_get_scheduled_actions(
+ array(
+ 'hook' => self::DAILY_HOOK,
+ 'group' => self::GROUP,
+ 'status' => \ActionScheduler_Store::STATUS_RUNNING,
+ 'modified' => new \DateTime( '@' . ( time() - self::RUNNING_FOR ) ),
+ 'modified_compare' => '>=',
+ 'per_page' => 1,
+ ),
+ 'ids'
+ );
+ return array() !== $running_daily;
+ }
+
+ /**
+ * Whether a requested scan is still waiting to start STALLED_AFTER seconds
+ * after it was due, and no other action has finished in that time either. A
+ * queue that is only behind is still running, so its scan stays queued.
+ */
+ public function is_stalled(): bool {
+ if ( ! function_exists( 'as_next_scheduled_action' ) ) {
+ return false;
+ }
+ // The timestamp of a pending action; true while it runs.
+ $due = as_next_scheduled_action( self::RUN_NOW_HOOK, array(), self::GROUP );
+ if ( ! is_int( $due ) || time() - $due <= self::STALLED_AFTER ) {
+ return false;
+ }
+ $recently_finished = as_get_scheduled_actions(
+ array(
+ 'status' => \ActionScheduler_Store::STATUS_COMPLETE,
+ 'modified' => new \DateTime( '@' . ( time() - self::STALLED_AFTER ) ),
+ 'modified_compare' => '>=',
+ 'per_page' => 1,
+ ),
+ 'ids'
+ );
+ return empty( $recently_finished );
+ }
+
+ /**
+ * Schedule the next daily run, then run the scan and store the report.
+ *
+ * @internal
+ */
+ public function run(): void {
+ if ( ! $this->features_controller->feature_is_enabled( StatusTab::FEATURE_ID ) ) {
+ return;
+ }
+ as_unschedule_all_actions( self::DAILY_HOOK, array(), self::GROUP );
+ as_schedule_single_action( time() + DAY_IN_SECONDS, self::DAILY_HOOK, array(), self::GROUP );
+
+ $this->agent_readiness->refresh_report();
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/StatusTab.php b/plugins/woocommerce/src/Internal/AgentReadiness/StatusTab.php
index 091d4441551..4e56dcbba14 100644
--- a/plugins/woocommerce/src/Internal/AgentReadiness/StatusTab.php
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/StatusTab.php
@@ -10,8 +10,8 @@ defined( 'ABSPATH' ) || exit;
/**
* The "Agent readiness" tab under WooCommerce > Status, shown only when the
- * agent_readiness feature is enabled. The scan runs when the merchant asks for
- * it and the report is kept for an hour.
+ * agent_readiness feature is enabled. "Run check" queues a background scan;
+ * the tab shows the last stored report.
*
* @since 11.4.0
*/
@@ -37,6 +37,13 @@ class StatusTab implements RegisterHooksInterface {
*/
private AgentReadiness $agent_readiness;
+ /**
+ * Background scan.
+ *
+ * @var ScheduledScan
+ */
+ private ScheduledScan $scheduled_scan;
+
/**
* Initialize dependencies.
*
@@ -44,10 +51,12 @@ class StatusTab implements RegisterHooksInterface {
*
* @param FeaturesController $features_controller Features controller.
* @param AgentReadiness $agent_readiness Readiness service.
+ * @param ScheduledScan $scheduled_scan Background scan.
*/
- final public function init( FeaturesController $features_controller, AgentReadiness $agent_readiness ): void {
+ final public function init( FeaturesController $features_controller, AgentReadiness $agent_readiness, ScheduledScan $scheduled_scan ): void {
$this->features_controller = $features_controller;
$this->agent_readiness = $agent_readiness;
+ $this->scheduled_scan = $scheduled_scan;
}
/**
@@ -88,7 +97,7 @@ class StatusTab implements RegisterHooksInterface {
}
/**
- * Run the scan when the "Run check" form is submitted, then redirect back to the tab.
+ * Queue a scan when the "Run check" form is submitted, then redirect back to the tab.
*
* @internal
*/
@@ -100,7 +109,7 @@ class StatusTab implements RegisterHooksInterface {
if ( ! current_user_can( 'manage_woocommerce' ) ) {
wp_die( esc_html__( 'You do not have permission to run this check.', 'woocommerce' ), 403 );
}
- $this->agent_readiness->refresh_report();
+ $this->scheduled_scan->queue();
wp_safe_redirect( $this->tab_url() );
exit;
}
@@ -111,14 +120,16 @@ class StatusTab implements RegisterHooksInterface {
* @internal
*/
public function render(): void {
- $report = $this->agent_readiness->get_cached_report();
+ $report = $this->agent_readiness->get_cached_report();
+ $pending = $this->scheduled_scan->is_pending();
+ $stalled = $pending && $this->scheduled_scan->is_stalled();
?>
<h2><?php esc_html_e( 'Agent readiness', 'woocommerce' ); ?></h2>
<p><?php esc_html_e( 'Checks your products against the product data specs of AI shopping channels, so agents such as ChatGPT, Google and Copilot can list and recommend them.', 'woocommerce' ); ?></p>
<form method="post" action="<?php echo esc_url( $this->tab_url() ); ?>">
<?php wp_nonce_field( self::NONCE_ACTION ); ?>
<p>
- <button type="submit" name="wc_agent_readiness_run" value="1" class="button button-primary"><?php esc_html_e( 'Run check', 'woocommerce' ); ?></button>
+ <button type="submit" name="wc_agent_readiness_run" value="1" class="button button-primary" <?php disabled( $pending ); ?>><?php esc_html_e( 'Run check', 'woocommerce' ); ?></button>
<?php if ( null !== $report && ! empty( $report['generated_at'] ) ) : ?>
<span class="description">
<?php
@@ -130,8 +141,15 @@ class StatusTab implements RegisterHooksInterface {
</p>
</form>
<?php
+ if ( $stalled ) {
+ echo '<div class="notice notice-warning inline"><p>' . esc_html__( 'The check has not started because scheduled actions do not seem to be running on this site. It runs once they do. See WooCommerce > Status > Scheduled Actions.', 'woocommerce' ) . '</p></div>';
+ } elseif ( $pending ) {
+ echo '<div class="notice notice-info inline"><p>' . esc_html__( 'Checking your products in the background. Reload this page in a minute to see the result.', 'woocommerce' ) . '</p></div>';
+ }
if ( null === $report ) {
- echo '<p>' . esc_html__( 'No check has run yet.', 'woocommerce' ) . '</p>';
+ if ( ! $pending ) {
+ echo '<p>' . esc_html__( 'No check has run yet.', 'woocommerce' ) . '</p>';
+ }
return;
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/ReadinessChecksTest.php b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/ReadinessChecksTest.php
index cee2ab466aa..3983257d1d4 100644
--- a/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/ReadinessChecksTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/ReadinessChecksTest.php
@@ -254,6 +254,77 @@ class ReadinessChecksTest extends WC_Unit_Test_Case {
$this->assertSame( $expected, $sut->channels()[ FeedChannels::GOOGLE ]['effect'] );
}
+ /**
+ * @testdox A variable product with no main image is listed with a published variation's own image, which is also checked for size.
+ */
+ public function test_missing_images_counts_variation_images(): void {
+ $sut = SimpleCheck::table()['missing_images'];
+ $variable = WC_Helper_Product::create_variation_product();
+ $variable->set_image_id( 0 );
+ $variable->save();
+ $variable = wc_get_product( $variable->get_id() );
+
+ $this->assertTrue( $sut->fails( $variable, $this->scan ), 'No image on the product or any variation' );
+
+ $variation = wc_get_product( $variable->get_children()[0] );
+ $variation->set_image_id( self::factory()->attachment->create( array( 'post_mime_type' => 'image/jpeg' ) ) );
+ $variation->save();
+
+ $this->assertFalse( $sut->fails( wc_get_product( $variable->get_id() ), $this->scan ), 'A variation with its own image can be listed' );
+
+ wp_update_attachment_metadata(
+ $variation->get_image_id(),
+ array(
+ 'width' => 300,
+ 'height' => 300,
+ )
+ );
+ $this->assertTrue( ( new ImageTooSmallCheck() )->fails( wc_get_product( $variable->get_id() ), $this->scan ), 'The variation image it lists with is checked for size' );
+ }
+
+ /**
+ * @testdox A variable product fails the image size check when any variation lists with a small image, whatever the variation order.
+ */
+ public function test_image_too_small_checks_every_variation_image(): void {
+ $sut = new ImageTooSmallCheck();
+ $image = function ( int $size ): int {
+ $id = self::factory()->attachment->create( array( 'post_mime_type' => 'image/jpeg' ) );
+ wp_update_attachment_metadata(
+ $id,
+ array(
+ 'width' => $size,
+ 'height' => $size,
+ )
+ );
+ return $id;
+ };
+ $variable = WC_Helper_Product::create_variation_product();
+ $variations = array_slice( $variable->get_children(), 0, 2 );
+ $variable->set_image_id( 0 );
+ $variable->save();
+
+ foreach ( array( array( 800, 100 ), array( 100, 800 ) ) as $sizes ) {
+ foreach ( $variations as $i => $variation_id ) {
+ $variation = wc_get_product( $variation_id );
+ $variation->set_image_id( $image( $sizes[ $i ] ) );
+ $variation->save();
+ }
+ $this->assertTrue( $sut->fails( wc_get_product( $variable->get_id() ), $this->scan ), implode( ' x ', $sizes ) . ': a small variation image fails in either order' );
+ }
+
+ foreach ( $variable->get_children() as $variation_id ) {
+ $variation = wc_get_product( $variation_id );
+ $variation->set_image_id( $image( 800 ) );
+ $variation->save();
+ }
+ $this->assertFalse( $sut->fails( wc_get_product( $variable->get_id() ), $this->scan ), 'All variation images are large enough' );
+
+ $variable = wc_get_product( $variable->get_id() );
+ $variable->set_image_id( $image( 100 ) );
+ $variable->save();
+ $this->assertFalse( $sut->fails( wc_get_product( $variable->get_id() ), $this->scan ), 'A small parent image no variation lists with is not counted' );
+ }
+
/**
* @testdox An image under 500 pixels on either side fails; one without recorded size does not.
*/
diff --git a/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/ReadinessRestControllerTest.php b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/ReadinessRestControllerTest.php
new file mode 100644
index 00000000000..f3ad1a143c0
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/ReadinessRestControllerTest.php
@@ -0,0 +1,112 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\AgentReadiness;
+
+use Automattic\WooCommerce\Internal\AgentReadiness\AgentReadiness;
+use Automattic\WooCommerce\Internal\AgentReadiness\ScheduledScan;
+use WC_REST_Unit_Test_Case;
+use WP_REST_Request;
+
+/**
+ * Tests for the agent readiness REST endpoints.
+ */
+class ReadinessRestControllerTest extends WC_REST_Unit_Test_Case {
+
+ /**
+ * Enable the feature before the routes register. A filter, not an option
+ * write, since the test transaction starts in parent::setUp().
+ */
+ public function setUp(): void {
+ add_filter( 'pre_option_woocommerce_feature_agent_readiness_enabled', array( __CLASS__, 'enabled' ) );
+ parent::setUp();
+ delete_option( AgentReadiness::REPORT_OPTION );
+ as_unschedule_all_actions( ScheduledScan::RUN_NOW_HOOK );
+ wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
+ }
+
+ /**
+ * Tear down test fixtures.
+ */
+ public function tearDown(): void {
+ as_unschedule_all_actions( ScheduledScan::RUN_NOW_HOOK );
+ as_unschedule_all_actions( ScheduledScan::DAILY_HOOK );
+ remove_filter( 'pre_option_woocommerce_feature_agent_readiness_enabled', array( __CLASS__, 'enabled' ) );
+ parent::tearDown();
+ }
+
+ /**
+ * The feature option's value while these tests run.
+ */
+ public static function enabled(): string {
+ return 'yes';
+ }
+
+ /**
+ * @testdox GET returns a null report before the first scan, then the stored report.
+ */
+ public function test_get_returns_stored_report(): void {
+ $response = $this->server->dispatch( new WP_REST_Request( 'GET', '/wc/v3/agent-readiness' ) );
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertNull( $response->get_data()['report'] );
+ $this->assertFalse( $response->get_data()['scan_pending'] );
+ $this->assertFalse( $response->get_data()['scan_stalled'] );
+
+ $report = wc_get_container()->get( AgentReadiness::class )->refresh_report();
+ $response = $this->server->dispatch( new WP_REST_Request( 'GET', '/wc/v3/agent-readiness' ) );
+ $this->assertSame( $report['generated_at'], $response->get_data()['report']['generated_at'] );
+ }
+
+ /**
+ * @testdox POST scan queues a background scan and answers 202 with scan_pending.
+ */
+ public function test_post_queues_scan(): void {
+ $response = $this->server->dispatch( new WP_REST_Request( 'POST', '/wc/v3/agent-readiness/scan' ) );
+
+ $this->assertSame( 202, $response->get_status() );
+ $this->assertTrue( $response->get_data()['scan_pending'] );
+ $this->assertTrue( as_has_scheduled_action( ScheduledScan::RUN_NOW_HOOK ) );
+ $this->assertFalse( $response->get_data()['scan_stalled'] );
+ }
+
+ /**
+ * @testdox When the queued scan has stalled, POST leaves it queued and answers with scan_stalled.
+ */
+ public function test_post_reports_stalled_scan(): void {
+ global $wpdb;
+ $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->prefix}actionscheduler_actions SET last_attempt_gmt = %s WHERE status = 'complete'", gmdate( 'Y-m-d H:i:s', time() - HOUR_IN_SECONDS ) ) );
+ as_schedule_single_action( time() - ScheduledScan::STALLED_AFTER - 60, ScheduledScan::RUN_NOW_HOOK, array(), ScheduledScan::GROUP );
+
+ $response = $this->server->dispatch( new WP_REST_Request( 'POST', '/wc/v3/agent-readiness/scan' ) );
+
+ $this->assertSame( 202, $response->get_status() );
+ $this->assertNull( $response->get_data()['report'], 'The scan must not run in the request.' );
+ $this->assertTrue( $response->get_data()['scan_pending'] );
+ $this->assertTrue( $response->get_data()['scan_stalled'] );
+ as_unschedule_all_actions( ScheduledScan::RUN_NOW_HOOK );
+ }
+
+ /**
+ * @testdox A user without manage_woocommerce can't read the report or queue a scan.
+ */
+ public function test_requires_manage_woocommerce(): void {
+ wp_set_current_user( self::factory()->user->create( array( 'role' => 'customer' ) ) );
+
+ $this->assertSame( 403, $this->server->dispatch( new WP_REST_Request( 'GET', '/wc/v3/agent-readiness' ) )->get_status() );
+ $this->assertSame( 403, $this->server->dispatch( new WP_REST_Request( 'POST', '/wc/v3/agent-readiness/scan' ) )->get_status() );
+ $this->assertFalse( as_has_scheduled_action( ScheduledScan::RUN_NOW_HOOK ) );
+ }
+
+ /**
+ * @testdox With the feature disabled the routes are not registered.
+ */
+ public function test_routes_absent_when_feature_disabled(): void {
+ remove_filter( 'pre_option_woocommerce_feature_agent_readiness_enabled', array( __CLASS__, 'enabled' ) );
+ update_option( 'woocommerce_feature_agent_readiness_enabled', 'no' );
+ global $wp_rest_server;
+ $wp_rest_server = new \WP_REST_Server();
+ do_action( 'rest_api_init', $wp_rest_server );
+
+ $this->assertSame( 404, $wp_rest_server->dispatch( new WP_REST_Request( 'GET', '/wc/v3/agent-readiness' ) )->get_status() );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/ScheduledScanTest.php b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/ScheduledScanTest.php
new file mode 100644
index 00000000000..799b3b3d9ed
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/ScheduledScanTest.php
@@ -0,0 +1,219 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\AgentReadiness;
+
+use Automattic\WooCommerce\Internal\AgentReadiness\AgentReadiness;
+use Automattic\WooCommerce\Internal\AgentReadiness\ScheduledScan;
+use WC_Helper_Product;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the background readiness scan.
+ */
+class ScheduledScanTest extends WC_Unit_Test_Case {
+
+ /**
+ * The System Under Test.
+ *
+ * @var ScheduledScan
+ */
+ private $sut;
+
+ /**
+ * Set up test fixtures.
+ */
+ public function setUp(): void {
+ parent::setUp();
+ update_option( 'woocommerce_feature_agent_readiness_enabled', 'yes' );
+ delete_option( AgentReadiness::REPORT_OPTION );
+ as_unschedule_all_actions( ScheduledScan::RUN_NOW_HOOK );
+ as_unschedule_all_actions( ScheduledScan::DAILY_HOOK );
+ $this->sut = wc_get_container()->get( ScheduledScan::class );
+ }
+
+ /**
+ * Tear down test fixtures.
+ */
+ public function tearDown(): void {
+ as_unschedule_all_actions( ScheduledScan::RUN_NOW_HOOK );
+ as_unschedule_all_actions( ScheduledScan::DAILY_HOOK );
+ delete_option( 'woocommerce_feature_agent_readiness_enabled' );
+ parent::tearDown();
+ }
+
+ /**
+ * Ids of the pending actions for a hook.
+ *
+ * @param string $hook Action hook.
+ * @return array<int, int>
+ */
+ private function pending( string $hook ): array {
+ return as_get_scheduled_actions(
+ array(
+ 'hook' => $hook,
+ 'status' => 'pending',
+ ),
+ 'ids'
+ );
+ }
+
+ /**
+ * @testdox Queueing twice before the scan runs leaves one pending scan.
+ */
+ public function test_queue_once(): void {
+ $this->assertFalse( $this->sut->is_pending() );
+
+ $this->sut->queue();
+ $this->sut->queue();
+
+ $this->assertTrue( $this->sut->is_pending() );
+ $this->assertCount( 1, $this->pending( ScheduledScan::RUN_NOW_HOOK ) );
+ }
+
+ /**
+ * @testdox Two requests that both see no pending scan, as when they run at the same time, still queue one scan.
+ */
+ public function test_racing_requests_queue_once(): void {
+ $racing = new class() extends ScheduledScan {
+ /**
+ * Each request checked before the other's scan was saved.
+ */
+ public function is_pending(): bool {
+ return false;
+ }
+ };
+
+ $racing->queue();
+ $racing->queue();
+
+ $this->assertCount( 1, $this->pending( ScheduledScan::RUN_NOW_HOOK ) );
+ }
+
+ /**
+ * @testdox A running daily scan counts as pending, so Run check queues nothing; the next daily scan, not yet due, doesn't.
+ */
+ public function test_running_daily_scan_is_pending(): void {
+ as_schedule_single_action( time() + DAY_IN_SECONDS, ScheduledScan::DAILY_HOOK, array(), ScheduledScan::GROUP );
+ $this->assertFalse( $this->sut->is_pending(), 'A daily scan waiting for tomorrow is not pending' );
+
+ $running = as_schedule_single_action( time(), ScheduledScan::DAILY_HOOK, array(), ScheduledScan::GROUP );
+ \ActionScheduler::store()->log_execution( $running );
+
+ $this->assertTrue( $this->sut->is_pending(), 'A running daily scan is pending' );
+ $this->sut->queue();
+ $this->assertCount( 0, $this->pending( ScheduledScan::RUN_NOW_HOOK ) );
+ }
+
+ /**
+ * @testdox A daily scan left running for over RUNNING_FOR, as when its process was killed, no longer blocks Run check.
+ */
+ public function test_abandoned_daily_scan_is_not_pending(): void {
+ global $wpdb;
+ $running = as_schedule_single_action( time(), ScheduledScan::DAILY_HOOK, array(), ScheduledScan::GROUP );
+ \ActionScheduler::store()->log_execution( $running );
+ $wpdb->update(
+ $wpdb->actionscheduler_actions,
+ array( 'last_attempt_gmt' => gmdate( 'Y-m-d H:i:s', time() - ScheduledScan::RUNNING_FOR - 60 ) ),
+ array( 'action_id' => $running )
+ );
+
+ $this->assertFalse( $this->sut->is_pending() );
+ $this->sut->queue();
+ $this->assertCount( 1, $this->pending( ScheduledScan::RUN_NOW_HOOK ), 'Run check queues a scan' );
+ }
+
+ /**
+ * @testdox A scan still waiting past STALLED_AFTER stays queued: a new request neither runs it nor queues another.
+ */
+ public function test_stalled_scan_stays_queued(): void {
+ delete_option( AgentReadiness::REPORT_OPTION );
+ $this->age_finished_actions();
+ as_schedule_single_action( time() - ScheduledScan::STALLED_AFTER - 60, ScheduledScan::RUN_NOW_HOOK, array(), ScheduledScan::GROUP );
+ $this->assertTrue( $this->sut->is_stalled() );
+
+ $this->sut->queue();
+
+ $this->assertNull( wc_get_container()->get( AgentReadiness::class )->get_cached_report(), 'The scan must not run in the request.' );
+ $this->assertCount( 1, $this->pending( ScheduledScan::RUN_NOW_HOOK ) );
+ }
+
+ /**
+ * @testdox A late scan on a queue that is still finishing other actions stays queued.
+ */
+ public function test_late_scan_on_a_busy_queue_is_not_stalled(): void {
+ $this->age_finished_actions();
+ as_schedule_single_action( time() - ScheduledScan::STALLED_AFTER - 60, ScheduledScan::RUN_NOW_HOOK, array(), ScheduledScan::GROUP );
+ $other = as_schedule_single_action( time() - 60, 'woocommerce_agent_readiness_test_other' );
+ \ActionScheduler::store()->mark_complete( $other );
+
+ $this->assertFalse( $this->sut->is_stalled() );
+ as_unschedule_all_actions( 'woocommerce_agent_readiness_test_other' );
+ }
+
+ /**
+ * @testdox A scan queued just now is pending, not stalled, and has a due date.
+ */
+ public function test_fresh_scan_is_not_stalled(): void {
+ $this->sut->queue();
+
+ $this->assertTrue( $this->sut->is_pending() );
+ $this->assertFalse( $this->sut->is_stalled() );
+ $this->assertIsInt( as_next_scheduled_action( ScheduledScan::RUN_NOW_HOOK, array(), ScheduledScan::GROUP ), 'The queued scan has a due date to tell a stall by.' );
+ }
+
+ /**
+ * @testdox A run stores the report and leaves exactly one daily run scheduled.
+ */
+ public function test_run_stores_report_and_schedules_daily_run(): void {
+ WC_Helper_Product::create_simple_product();
+
+ $this->sut->run();
+ $this->sut->run();
+
+ $report = wc_get_container()->get( AgentReadiness::class )->get_cached_report();
+ $this->assertSame( 1, $report['products'] );
+ $this->assertCount( 1, $this->pending( ScheduledScan::DAILY_HOOK ) );
+ }
+
+ /**
+ * @testdox A scan that throws still leaves the next daily run scheduled.
+ */
+ public function test_failed_scan_keeps_daily_run(): void {
+ add_filter(
+ 'woocommerce_agent_readiness_rules',
+ static function () {
+ throw new \RuntimeException( 'Rules callback failed' );
+ }
+ );
+
+ try {
+ $this->sut->run();
+ } catch ( \RuntimeException $e ) {
+ unset( $e );
+ }
+
+ $this->assertCount( 1, $this->pending( ScheduledScan::DAILY_HOOK ) );
+ }
+
+ /**
+ * @testdox With the feature disabled a run stores nothing and schedules nothing, ending the daily chain.
+ */
+ public function test_run_with_feature_disabled(): void {
+ update_option( 'woocommerce_feature_agent_readiness_enabled', 'no' );
+
+ $this->sut->run();
+
+ $this->assertNull( wc_get_container()->get( AgentReadiness::class )->get_cached_report() );
+ $this->assertFalse( as_has_scheduled_action( ScheduledScan::DAILY_HOOK ) );
+ }
+
+ /**
+ * Date every finished action an hour back, so only what the test does next
+ * counts as recent queue activity.
+ */
+ private function age_finished_actions(): void {
+ global $wpdb;
+ $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->prefix}actionscheduler_actions SET last_attempt_gmt = %s WHERE status = 'complete'", gmdate( 'Y-m-d H:i:s', time() - HOUR_IN_SECONDS ) ) );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/StatusTabTest.php b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/StatusTabTest.php
index ea7346c320e..009bfe90e78 100644
--- a/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/StatusTabTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/StatusTabTest.php
@@ -4,6 +4,7 @@ declare( strict_types = 1 );
namespace Automattic\WooCommerce\Tests\Internal\AgentReadiness;
use Automattic\WooCommerce\Internal\AgentReadiness\AgentReadiness;
+use Automattic\WooCommerce\Internal\AgentReadiness\ScheduledScan;
use Automattic\WooCommerce\Internal\AgentReadiness\StatusTab;
use WC_Helper_Product;
use WC_Unit_Test_Case;
@@ -55,7 +56,7 @@ class StatusTabTest extends WC_Unit_Test_Case {
* @testdox The tab asks for a first run when no report is cached.
*/
public function test_render_without_report(): void {
- delete_transient( AgentReadiness::REPORT_TRANSIENT );
+ delete_option( AgentReadiness::REPORT_OPTION );
ob_start();
wc_get_container()->get( StatusTab::class )->render();
@@ -64,4 +65,45 @@ class StatusTabTest extends WC_Unit_Test_Case {
$this->assertStringContainsString( 'No check has run yet.', $html );
$this->assertStringContainsString( 'name="wc_agent_readiness_run"', $html );
}
+
+ /**
+ * @testdox While a scan waits, the button is disabled and the tab says so; once it stalls, the button stays disabled and the tab says scheduled actions aren't running.
+ */
+ public function test_render_pending_and_stalled(): void {
+ delete_option( AgentReadiness::REPORT_OPTION );
+ as_unschedule_all_actions( ScheduledScan::RUN_NOW_HOOK );
+ wc_get_container()->get( ScheduledScan::class )->queue();
+
+ $html = $this->render();
+ $this->assertStringContainsString( 'Checking your products in the background.', $html );
+ $this->assertStringNotContainsString( 'No check has run yet.', $html, 'A first scan is already on its way' );
+ $this->assertMatchesRegularExpression( '/name="wc_agent_readiness_run"[^>]*disabled/', $html );
+
+ as_unschedule_all_actions( ScheduledScan::RUN_NOW_HOOK );
+ $this->age_finished_actions();
+ as_schedule_single_action( time() - ScheduledScan::STALLED_AFTER - 60, ScheduledScan::RUN_NOW_HOOK, array(), ScheduledScan::GROUP );
+
+ $html = $this->render();
+ $this->assertStringContainsString( 'The check has not started because scheduled actions', $html );
+ $this->assertMatchesRegularExpression( '/name="wc_agent_readiness_run"[^>]*disabled/', $html );
+ as_unschedule_all_actions( ScheduledScan::RUN_NOW_HOOK );
+ }
+
+ /**
+ * The tab's HTML.
+ */
+ private function render(): string {
+ ob_start();
+ wc_get_container()->get( StatusTab::class )->render();
+ return (string) ob_get_clean();
+ }
+
+ /**
+ * Date every finished action an hour back, so only what the test does next
+ * counts as recent queue activity.
+ */
+ private function age_finished_actions(): void {
+ global $wpdb;
+ $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->prefix}actionscheduler_actions SET last_attempt_gmt = %s WHERE status = 'complete'", gmdate( 'Y-m-d H:i:s', time() - HOUR_IN_SECONDS ) ) );
+ }
}