Commit cae083b1c89 for woocommerce
commit cae083b1c8945ac7b351d964847973ca29f0f475
Author: AnnaMag <AnnaMag@users.noreply.github.com>
Date: Fri Nov 13 11:20:08 2026 +0100
Agent readiness: ability to check a product again or queue a store check (#70438)
* Agent readiness: ability to check a product again or queue a store check
Part of #70438.
* Agent readiness: the run ability says when the queued check has stalled
Queueing the whole-store check now also answers scan_stalled, like the
read ability and GET wc/v3/agent-readiness, so an agent on a store whose
scheduled actions have stopped doesn't wait for a report that won't change.
* Agent readiness: register the run ability through AbilitiesLoader
RunCheckAbility is its definition class, like ReportAbility for the read
ability, so the loader registers it with the Ability class.
* Agent readiness: mark the changelog entry as a patch
A Comment-only changelog entry must be a patch; changelogger validate rejects it as minor.
* Agent readiness: define each ability on its own class
diff --git a/plugins/woocommerce/changelog/add-agent-readiness-run-ability b/plugins/woocommerce/changelog/add-agent-readiness-run-ability
new file mode 100644
index 00000000000..51b76c3209e
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-agent-readiness-run-ability
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+Comment: Agent readiness: ability that checks a product again or queues a store check, behind an experimental flag.
+
diff --git a/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php b/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php
index c8c0e6d4525..ea3aae4a023 100644
--- a/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php
+++ b/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php
@@ -33,6 +33,7 @@ use Automattic\WooCommerce\Internal\Abilities\Domain\ProductReviewUpdate;
use Automattic\WooCommerce\Internal\Abilities\Domain\ShippingZonesQuery;
use Automattic\WooCommerce\Internal\Abilities\Domain\TaxRatesQuery;
use Automattic\WooCommerce\Internal\AgentReadiness\ReportAbility;
+use Automattic\WooCommerce\Internal\AgentReadiness\RunCheckAbility;
defined( 'ABSPATH' ) || exit;
@@ -74,6 +75,7 @@ class AbilitiesLoader {
ProductDelete::class,
ProductUpdate::class,
ReportAbility::class,
+ RunCheckAbility::class,
ShippingZonesQuery::class,
TaxRatesQuery::class,
CustomersQuery::class,
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/README.md b/plugins/woocommerce/src/Internal/AgentReadiness/README.md
index f73e9794bdf..760491e8157 100644
--- a/plugins/woocommerce/src/Internal/AgentReadiness/README.md
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/README.md
@@ -134,9 +134,10 @@ The scan walks published simple and variable products shown in the shop catalog
## Abilities
-While the feature is enabled, `AbilitiesLoader` registers these abilities in the WordPress Abilities API with the `Ability` class, which converts their input to the schema types. Their definition classes in this directory read the arguments from `ReadinessAbilities::registration_args()`. They show in REST and to MCP clients, for users with `manage_woocommerce`:
+While the feature is enabled, `AbilitiesLoader` registers these abilities in the WordPress Abilities API with the `Ability` class, which converts their input to the schema types. Each definition class in this directory owns its arguments. They show in REST and to MCP clients, for users with `manage_woocommerce`:
- `woocommerce/get-agent-readiness` and `GET wc/v3/agent-readiness` both return `{ report, scan_pending, scan_stalled }` with `store_checks` read now (`AgentReadiness::get_store_checks()`): settings changed since the scan show their current state, and store output checks come from the stored report.
+- `woocommerce/run-agent-readiness-check` with `product_id` checks that product now (`check_product()`) and returns its failing checks with label and fix, and its status per channel. Without one it queues the background scan, like `POST wc/v3/agent-readiness/scan`, and returns `scan_pending`, `scan_stalled` and `report_generated_at`, so the agent knows when to read the report again, or that it won't change until scheduled actions run.
## Agentic commerce settings page
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/ReadinessAbilities.php b/plugins/woocommerce/src/Internal/AgentReadiness/ReadinessAbilities.php
deleted file mode 100644
index 7e1ef518326..00000000000
--- a/plugins/woocommerce/src/Internal/AgentReadiness/ReadinessAbilities.php
+++ /dev/null
@@ -1,256 +0,0 @@
-<?php
-declare( strict_types = 1 );
-
-namespace Automattic\WooCommerce\Internal\AgentReadiness;
-
-use Automattic\WooCommerce\Internal\Features\FeaturesController;
-
-defined( 'ABSPATH' ) || exit;
-
-/**
- * Abilities that let an agent connected to the store (over MCP) read the
- * readiness report. Registered only while the agent_readiness feature is
- * enabled, and only for users who can manage WooCommerce.
- *
- * AbilitiesLoader registers them through the definition classes in this directory,
- * which read their arguments from registration_args().
- *
- * @since 11.4.0
- */
-class ReadinessAbilities {
-
- public const GET_REPORT = 'woocommerce/get-agent-readiness';
-
- /**
- * 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;
- }
-
- /**
- * Whether the abilities are registered: while the feature is enabled.
- *
- * @internal
- */
- public function is_enabled(): bool {
- return $this->features_controller->feature_is_enabled( StatusTab::FEATURE_ID );
- }
-
- /**
- * Registration arguments of an ability, for the definition class AbilitiesLoader registers it with.
- *
- * @internal
- *
- * @param string $name Ability name.
- * @return array<string, mixed>
- */
- public function registration_args( string $name ): array {
- if ( self::GET_REPORT === $name ) {
- return array(
- 'label' => __( 'Get agent readiness', 'woocommerce' ),
- 'description' => __( 'Read the last agent readiness check: store settings and pages that keep AI shopping agents from finding or buying products, and product problems per channel (ChatGPT, Google, Copilot), each with why it matters and what to change. Store settings are read now; product results come from the last background check.', 'woocommerce' ),
- 'category' => 'woocommerce',
- 'input_schema' => array(
- 'type' => 'object',
- 'properties' => array(),
- 'additionalProperties' => false,
- 'default' => array(),
- ),
- 'output_schema' => self::report_schema(),
- 'execute_callback' => array( $this, 'get_report' ),
- 'permission_callback' => array( $this, 'can_manage' ),
- 'meta' => array(
- 'show_in_rest' => true,
- 'mcp' => array(
- 'public' => true,
- 'type' => 'tool',
- ),
- 'annotations' => array(
- 'readonly' => true,
- 'idempotent' => true,
- 'destructive' => false,
- ),
- ),
- );
- }
- return array();
- }
-
- /**
- * Whether the current user may use the abilities.
- *
- * @internal
- */
- public function can_manage(): bool {
- return current_user_can( 'manage_woocommerce' );
- }
-
- /**
- * The stored report with store checks as they stand now, whether a scan is pending, and whether it has stalled.
- *
- * @internal
- *
- * @param mixed $input Ability input; this ability takes none.
- * @return array{report:array<string,mixed>|null,scan_pending:bool,scan_stalled:bool}
- */
- public function get_report( $input = null ): array {
- unset( $input );
- $report = $this->agent_readiness->get_cached_report();
- if ( null !== $report ) {
- $report['store_checks'] = $this->agent_readiness->get_store_checks( $report );
- }
- $pending = $this->scheduled_scan->is_pending();
- return array(
- 'report' => $report,
- 'scan_pending' => $pending,
- 'scan_stalled' => $pending && $this->scheduled_scan->is_stalled(),
- );
- }
-
- /**
- * Output schema of the report ability, following the report format in this directory's README.
- *
- * @return array<string, mixed>
- */
- private static function report_schema(): array {
- $channel_map = array(
- 'type' => 'object',
- 'description' => __( 'Feed field and effect by channel id: not_listable when the channel requires the field, gaps when the product lists with a gap. Empty for checks about the product page.', 'woocommerce' ),
- 'additionalProperties' => array(
- 'type' => 'object',
- 'properties' => array(
- 'field' => array( 'type' => 'string' ),
- 'effect' => array(
- 'type' => 'string',
- 'enum' => array( ReadinessRule::EFFECT_NOT_LISTABLE, ReadinessRule::EFFECT_GAPS ),
- ),
- ),
- ),
- );
- return array(
- 'type' => 'object',
- 'properties' => array(
- 'report' => array(
- 'type' => array( 'object', 'null' ),
- 'description' => __( 'The last agent readiness check, or null before the first one.', 'woocommerce' ),
- 'properties' => array(
- 'generated_at' => array(
- 'type' => 'string',
- 'format' => 'date-time',
- ),
- 'products' => array(
- 'type' => 'integer',
- 'description' => __( 'Published simple and variable products checked.', 'woocommerce' ),
- ),
- 'hidden_from_catalog' => array(
- 'type' => 'integer',
- 'description' => __( 'Products not checked because the merchant hid them from the shop catalog.', 'woocommerce' ),
- ),
- 'unsupported_types' => array(
- 'type' => 'integer',
- 'description' => __( 'Products not checked because agents don\'t support their type yet.', 'woocommerce' ),
- ),
- 'store_checks' => array(
- 'type' => 'array',
- 'items' => array(
- 'type' => 'object',
- 'properties' => array(
- 'id' => array( 'type' => 'string' ),
- 'ok' => array(
- 'type' => array( 'boolean', 'null' ),
- 'description' => __( 'True when the store passes, false when it fails, null when the check could not run.', 'woocommerce' ),
- ),
- 'blocks' => array(
- 'type' => 'string',
- 'description' => __( 'What a failure stops: listing (every product everywhere), ucp (agents using UCP) or checkout (agent orders).', 'woocommerce' ),
- ),
- 'level' => array(
- 'type' => 'string',
- 'enum' => array( StoreConditions::LEVEL_REQUIRED, StoreConditions::LEVEL_RECOMMENDED ),
- ),
- 'name' => array( 'type' => 'string' ),
- 'label' => array( 'type' => 'string' ),
- 'why' => array( 'type' => 'string' ),
- 'fix' => array( 'type' => 'string' ),
- 'fix_url' => array( 'type' => 'string' ),
- ),
- ),
- ),
- 'channels' => array(
- 'type' => 'object',
- 'additionalProperties' => array(
- 'type' => 'object',
- 'properties' => array(
- 'name' => array( 'type' => 'string' ),
- 'listable' => array( 'type' => 'integer' ),
- 'not_listable' => array( 'type' => 'integer' ),
- 'with_gaps' => array( 'type' => 'integer' ),
- 'complete' => array( 'type' => 'integer' ),
- ),
- ),
- ),
- 'checks' => array(
- 'type' => 'array',
- 'items' => array(
- 'type' => 'object',
- 'properties' => array(
- 'id' => array( 'type' => 'string' ),
- 'label' => array( 'type' => 'string' ),
- 'why' => array( 'type' => 'string' ),
- 'fix' => array( 'type' => 'string' ),
- 'channels' => $channel_map,
- 'count' => array( 'type' => 'integer' ),
- 'product_ids' => array(
- 'type' => 'array',
- 'description' => __( 'Up to 10 failing products, most recently modified first.', 'woocommerce' ),
- 'items' => array( 'type' => 'integer' ),
- ),
- 'fix_url' => array( 'type' => 'string' ),
- ),
- ),
- ),
- 'truncated' => array( 'type' => 'boolean' ),
- ),
- ),
- 'scan_pending' => array(
- 'type' => 'boolean',
- 'description' => __( 'Whether a requested check has not finished yet.', 'woocommerce' ),
- ),
- 'scan_stalled' => array(
- 'type' => 'boolean',
- 'description' => __( 'Whether the requested check has not started because scheduled actions are not running on the store. It stays queued and runs once they do; until then the report does not change.', 'woocommerce' ),
- ),
- ),
- );
- }
-}
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/ReadinessAbility.php b/plugins/woocommerce/src/Internal/AgentReadiness/ReadinessAbility.php
new file mode 100644
index 00000000000..d7a65f9369e
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/ReadinessAbility.php
@@ -0,0 +1,56 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\AgentReadiness;
+
+use Automattic\WooCommerce\Internal\Features\FeaturesController;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * What the agent readiness abilities share: the feature gate, the permission, and the channel schema.
+ *
+ * @since 11.4.0
+ */
+trait ReadinessAbility {
+
+ /**
+ * Ability name while the feature is on, or an empty string so AbilitiesLoader skips it.
+ *
+ * @param string $name Ability name.
+ * @return string
+ */
+ protected static function name_when_enabled( string $name ): string {
+ $features = wc_get_container()->get( FeaturesController::class );
+ return $features->feature_is_enabled( StatusTab::FEATURE_ID ) ? $name : '';
+ }
+
+ /**
+ * Whether the current user may use the ability.
+ */
+ public static function can_manage(): bool {
+ return current_user_can( 'manage_woocommerce' );
+ }
+
+ /**
+ * Schema of a check's field and effect by channel.
+ *
+ * @return array<string, mixed>
+ */
+ protected static function channel_map_schema(): array {
+ return array(
+ 'type' => 'object',
+ 'description' => __( 'Feed field and effect by channel id: not_listable when the channel requires the field, gaps when the product lists with a gap. Empty for checks about the product page.', 'woocommerce' ),
+ 'additionalProperties' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'field' => array( 'type' => 'string' ),
+ 'effect' => array(
+ 'type' => 'string',
+ 'enum' => array( ReadinessRule::EFFECT_NOT_LISTABLE, ReadinessRule::EFFECT_GAPS ),
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/ReportAbility.php b/plugins/woocommerce/src/Internal/AgentReadiness/ReportAbility.php
index 987c575d2bc..13891497d18 100644
--- a/plugins/woocommerce/src/Internal/AgentReadiness/ReportAbility.php
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/ReportAbility.php
@@ -8,28 +8,183 @@ use Automattic\WooCommerce\Abilities\AbilityDefinition;
defined( 'ABSPATH' ) || exit;
/**
- * Definition of the ability that reads the readiness report, for AbilitiesLoader.
+ * Ability that reads the readiness report, for AbilitiesLoader.
* No name while the feature is disabled, so the loader skips it.
*
* @since 11.4.0
*/
class ReportAbility implements AbilityDefinition {
+ use ReadinessAbility;
+
+ public const NAME = 'woocommerce/get-agent-readiness';
+
/**
* Ability name, or an empty string while the feature is disabled.
*
* @return string
*/
public static function get_name(): string {
- return wc_get_container()->get( ReadinessAbilities::class )->is_enabled() ? ReadinessAbilities::GET_REPORT : '';
+ return self::name_when_enabled( self::NAME );
}
/**
* Registration arguments.
*
- * @return array
+ * @return array<string, mixed>
*/
public static function get_registration_args(): array {
- return wc_get_container()->get( ReadinessAbilities::class )->registration_args( ReadinessAbilities::GET_REPORT );
+ return array(
+ 'label' => __( 'Get agent readiness', 'woocommerce' ),
+ 'description' => __( 'Read the last agent readiness check: store settings and pages that keep AI shopping agents from finding or buying products, and product problems per channel (ChatGPT, Google, Copilot), each with why it matters and what to change. Store settings are read now; product results come from the last background check.', 'woocommerce' ),
+ 'category' => 'woocommerce',
+ 'input_schema' => array(
+ 'type' => 'object',
+ 'properties' => array(),
+ 'additionalProperties' => false,
+ 'default' => array(),
+ ),
+ 'output_schema' => self::output_schema(),
+ 'execute_callback' => array( self::class, 'execute' ),
+ 'permission_callback' => array( self::class, 'can_manage' ),
+ 'meta' => array(
+ 'show_in_rest' => true,
+ 'mcp' => array(
+ 'public' => true,
+ 'type' => 'tool',
+ ),
+ 'annotations' => array(
+ 'readonly' => true,
+ 'idempotent' => true,
+ 'destructive' => false,
+ ),
+ ),
+ );
+ }
+
+ /**
+ * The stored report with store checks as they stand now, whether a scan is pending, and whether it has stalled.
+ *
+ * @param array<string, mixed> $input Ability input; this ability takes none.
+ * @return array{report:array<string,mixed>|null,scan_pending:bool,scan_stalled:bool}
+ */
+ public static function execute( array $input ): array {
+ unset( $input );
+ $readiness = wc_get_container()->get( AgentReadiness::class );
+ $scan = wc_get_container()->get( ScheduledScan::class );
+ $report = $readiness->get_cached_report();
+ if ( null !== $report ) {
+ $report['store_checks'] = $readiness->get_store_checks( $report );
+ }
+ $pending = $scan->is_pending();
+ return array(
+ 'report' => $report,
+ 'scan_pending' => $pending,
+ 'scan_stalled' => $pending && $scan->is_stalled(),
+ );
+ }
+
+ /**
+ * Output schema, following the report format in this directory's README.
+ *
+ * @return array<string, mixed>
+ */
+ private static function output_schema(): array {
+ $channel_map = self::channel_map_schema();
+ return array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'report' => array(
+ 'type' => array( 'object', 'null' ),
+ 'description' => __( 'The last agent readiness check, or null before the first one.', 'woocommerce' ),
+ 'properties' => array(
+ 'generated_at' => array(
+ 'type' => 'string',
+ 'format' => 'date-time',
+ ),
+ 'products' => array(
+ 'type' => 'integer',
+ 'description' => __( 'Published simple and variable products checked.', 'woocommerce' ),
+ ),
+ 'hidden_from_catalog' => array(
+ 'type' => 'integer',
+ 'description' => __( 'Products not checked because the merchant hid them from the shop catalog.', 'woocommerce' ),
+ ),
+ 'unsupported_types' => array(
+ 'type' => 'integer',
+ 'description' => __( 'Products not checked because agents don\'t support their type yet.', 'woocommerce' ),
+ ),
+ 'store_checks' => array(
+ 'type' => 'array',
+ 'items' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'id' => array( 'type' => 'string' ),
+ 'ok' => array(
+ 'type' => array( 'boolean', 'null' ),
+ 'description' => __( 'True when the store passes, false when it fails, null when the check could not run.', 'woocommerce' ),
+ ),
+ 'blocks' => array(
+ 'type' => 'string',
+ 'description' => __( 'What a failure stops: listing (every product everywhere), ucp (agents using UCP) or checkout (agent orders).', 'woocommerce' ),
+ ),
+ 'level' => array(
+ 'type' => 'string',
+ 'enum' => array( StoreConditions::LEVEL_REQUIRED, StoreConditions::LEVEL_RECOMMENDED ),
+ ),
+ 'name' => array( 'type' => 'string' ),
+ 'label' => array( 'type' => 'string' ),
+ 'why' => array( 'type' => 'string' ),
+ 'fix' => array( 'type' => 'string' ),
+ 'fix_url' => array( 'type' => 'string' ),
+ ),
+ ),
+ ),
+ 'channels' => array(
+ 'type' => 'object',
+ 'additionalProperties' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'name' => array( 'type' => 'string' ),
+ 'listable' => array( 'type' => 'integer' ),
+ 'not_listable' => array( 'type' => 'integer' ),
+ 'with_gaps' => array( 'type' => 'integer' ),
+ 'complete' => array( 'type' => 'integer' ),
+ ),
+ ),
+ ),
+ 'checks' => array(
+ 'type' => 'array',
+ 'items' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'id' => array( 'type' => 'string' ),
+ 'label' => array( 'type' => 'string' ),
+ 'why' => array( 'type' => 'string' ),
+ 'fix' => array( 'type' => 'string' ),
+ 'channels' => $channel_map,
+ 'count' => array( 'type' => 'integer' ),
+ 'product_ids' => array(
+ 'type' => 'array',
+ 'description' => __( 'Up to 10 failing products, most recently modified first.', 'woocommerce' ),
+ 'items' => array( 'type' => 'integer' ),
+ ),
+ 'fix_url' => array( 'type' => 'string' ),
+ ),
+ ),
+ ),
+ 'truncated' => array( 'type' => 'boolean' ),
+ ),
+ ),
+ 'scan_pending' => array(
+ 'type' => 'boolean',
+ 'description' => __( 'Whether a requested check has not finished yet.', 'woocommerce' ),
+ ),
+ 'scan_stalled' => array(
+ 'type' => 'boolean',
+ 'description' => __( 'Whether the requested check has not started because scheduled actions are not running on the store. It stays queued and runs once they do; until then the report does not change.', 'woocommerce' ),
+ ),
+ ),
+ );
}
}
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/RunCheckAbility.php b/plugins/woocommerce/src/Internal/AgentReadiness/RunCheckAbility.php
new file mode 100644
index 00000000000..bde66aa8ade
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/RunCheckAbility.php
@@ -0,0 +1,173 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\AgentReadiness;
+
+use Automattic\WooCommerce\Abilities\AbilityDefinition;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Ability that checks a product again or queues a store check, for AbilitiesLoader.
+ * No name while the feature is disabled, so the loader skips it.
+ *
+ * @since 11.4.0
+ */
+class RunCheckAbility implements AbilityDefinition {
+
+ use ReadinessAbility;
+
+ public const NAME = 'woocommerce/run-agent-readiness-check';
+
+ /**
+ * Ability name, or an empty string while the feature is disabled.
+ *
+ * @return string
+ */
+ public static function get_name(): string {
+ return self::name_when_enabled( self::NAME );
+ }
+
+ /**
+ * Registration arguments.
+ *
+ * @return array<string, mixed>
+ */
+ public static function get_registration_args(): array {
+ return array(
+ 'label' => __( 'Run agent readiness check', 'woocommerce' ),
+ 'description' => __( 'Check one product again now, after fixing it, and get its failing checks and status per channel. Without a product id, queue a check of the whole store in the background; read the result with woocommerce/get-agent-readiness once scan_pending is false. When scan_stalled is true, scheduled actions are not running on the store and the check waits for them.', 'woocommerce' ),
+ 'category' => 'woocommerce',
+ 'input_schema' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'product_id' => array(
+ 'type' => 'integer',
+ 'description' => __( 'Product or variation to check now. A variation is checked as its parent product.', 'woocommerce' ),
+ 'minimum' => 1,
+ ),
+ ),
+ 'additionalProperties' => false,
+ 'default' => array(),
+ ),
+ 'output_schema' => self::output_schema(),
+ 'execute_callback' => array( self::class, 'execute' ),
+ 'permission_callback' => array( self::class, 'can_manage' ),
+ 'meta' => array(
+ 'show_in_rest' => true,
+ 'mcp' => array(
+ 'public' => true,
+ 'type' => 'tool',
+ ),
+ 'annotations' => array(
+ 'readonly' => false,
+ 'idempotent' => false,
+ 'destructive' => false,
+ ),
+ ),
+ );
+ }
+
+ /**
+ * Check one product now, or queue a check of the whole store.
+ *
+ * @param array<string, mixed> $input Ability input.
+ * @return array<string, mixed>|\WP_Error
+ */
+ public static function execute( array $input ) {
+ $product_id = $input['product_id'] ?? 0;
+ if ( 0 === $product_id ) {
+ $scan = wc_get_container()->get( ScheduledScan::class );
+ $scan->queue();
+ $report = wc_get_container()->get( AgentReadiness::class )->get_cached_report();
+ $pending = $scan->is_pending();
+ $generated_at = is_array( $report ) ? ( $report['generated_at'] ?? null ) : null;
+ return array(
+ 'scan_pending' => $pending,
+ 'scan_stalled' => $pending && $scan->is_stalled(),
+ 'report_generated_at' => is_string( $generated_at ) ? $generated_at : null,
+ );
+ }
+
+ $readiness = wc_get_container()->get( AgentReadiness::class );
+ $product = wc_get_product( $product_id );
+ if ( ! $product instanceof \WC_Product ) {
+ return new \WP_Error( 'woocommerce_agent_readiness_product_not_found', __( 'Product not found.', 'woocommerce' ), array( 'status' => 404 ) );
+ }
+ $result = $readiness->check_product( $product );
+ if ( null === $result ) {
+ return new \WP_Error( 'woocommerce_agent_readiness_product_not_checked', __( 'Only published simple and variable products shown in the shop catalog are checked.', 'woocommerce' ), array( 'status' => 400 ) );
+ }
+
+ $rules = $readiness->get_default_rules();
+ $checks = array();
+ foreach ( $result['checks'] as $id => $channels ) {
+ $checks[] = array(
+ 'id' => (string) $id,
+ 'label' => isset( $rules[ $id ] ) ? $rules[ $id ]->label() : '',
+ 'fix' => isset( $rules[ $id ] ) ? $rules[ $id ]->fix() : '',
+ 'channels' => $channels,
+ );
+ }
+ return array(
+ 'product' => array(
+ 'product_id' => $result['product_id'],
+ 'edit_url' => admin_url( 'post.php?post=' . $result['product_id'] . '&action=edit' ),
+ 'statuses' => $result['statuses'],
+ 'checks' => $checks,
+ ),
+ );
+ }
+
+ /**
+ * Output schema of the run ability.
+ *
+ * @return array<string, mixed>
+ */
+ private static function output_schema(): array {
+ return array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'scan_pending' => array(
+ 'type' => 'boolean',
+ 'description' => __( 'Whole store: whether the queued check has not finished yet.', 'woocommerce' ),
+ ),
+ 'scan_stalled' => array(
+ 'type' => 'boolean',
+ 'description' => __( 'Whole store: whether the queued check has not started because scheduled actions are not running on the store. It runs once they do; until then the report does not change.', 'woocommerce' ),
+ ),
+ 'report_generated_at' => array(
+ 'type' => array( 'string', 'null' ),
+ 'description' => __( 'Whole store: when the report agents can read now was made; it changes when the queued check finishes.', 'woocommerce' ),
+ ),
+ 'product' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'product_id' => array( 'type' => 'integer' ),
+ 'edit_url' => array( 'type' => 'string' ),
+ 'statuses' => array(
+ 'type' => 'object',
+ 'description' => __( 'Status by channel id: not_listable, with_gaps or complete.', 'woocommerce' ),
+ 'additionalProperties' => array(
+ 'type' => 'string',
+ 'enum' => array( ProductScan::NOT_LISTABLE, ProductScan::WITH_GAPS, ProductScan::COMPLETE ),
+ ),
+ ),
+ 'checks' => array(
+ 'type' => 'array',
+ 'items' => array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'id' => array( 'type' => 'string' ),
+ 'label' => array( 'type' => 'string' ),
+ 'fix' => array( 'type' => 'string' ),
+ 'channels' => self::channel_map_schema(),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/ReadinessAbilitiesTest.php b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/ReadinessAbilitiesTest.php
index e9153a94cd0..7235330f8d7 100644
--- a/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/ReadinessAbilitiesTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/ReadinessAbilitiesTest.php
@@ -6,7 +6,8 @@ namespace Automattic\WooCommerce\Tests\Internal\AgentReadiness;
use Automattic\WooCommerce\Internal\Abilities\Ability;
use Automattic\WooCommerce\Internal\Abilities\AbilitiesLoader;
use Automattic\WooCommerce\Internal\AgentReadiness\AgentReadiness;
-use Automattic\WooCommerce\Internal\AgentReadiness\ReadinessAbilities;
+use Automattic\WooCommerce\Internal\AgentReadiness\ReportAbility;
+use Automattic\WooCommerce\Internal\AgentReadiness\RunCheckAbility;
use Automattic\WooCommerce\Internal\AgentReadiness\ScheduledScan;
use WC_Helper_Product;
use WC_Unit_Test_Case;
@@ -16,13 +17,6 @@ use WC_Unit_Test_Case;
*/
class ReadinessAbilitiesTest extends WC_Unit_Test_Case {
- /**
- * The System Under Test.
- *
- * @var ReadinessAbilities
- */
- private $sut;
-
/**
* Action counts to restore.
*
@@ -56,7 +50,6 @@ class ReadinessAbilitiesTest extends WC_Unit_Test_Case {
}
update_option( 'woocommerce_feature_agent_readiness_enabled', 'yes' );
wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
- $this->sut = wc_get_container()->get( ReadinessAbilities::class );
$this->register();
}
@@ -66,7 +59,7 @@ class ReadinessAbilitiesTest extends WC_Unit_Test_Case {
public function tearDown(): void {
global $wp_actions;
try {
- foreach ( array( ReadinessAbilities::GET_REPORT ) as $name ) {
+ foreach ( array( ReportAbility::NAME, RunCheckAbility::NAME ) as $name ) {
if ( wp_has_ability( $name ) ) {
wp_unregister_ability( $name );
}
@@ -88,8 +81,10 @@ class ReadinessAbilitiesTest extends WC_Unit_Test_Case {
* first, which runs the hooked registration once already.
*/
private function register(): void {
- if ( wp_has_ability( ReadinessAbilities::GET_REPORT ) ) {
- wp_unregister_ability( ReadinessAbilities::GET_REPORT );
+ foreach ( array( ReportAbility::NAME, RunCheckAbility::NAME ) as $name ) {
+ if ( wp_has_ability( $name ) ) {
+ wp_unregister_ability( $name );
+ }
}
$callback = array( AbilitiesLoader::class, 'register_abilities' );
add_action( 'wp_abilities_api_init', $callback );
@@ -101,27 +96,32 @@ class ReadinessAbilitiesTest extends WC_Unit_Test_Case {
* @testdox AbilitiesLoader registers the abilities with the Ability class, only while the feature is enabled.
*/
public function test_registered_only_with_feature(): void {
- $this->assertTrue( wp_has_ability( ReadinessAbilities::GET_REPORT ) );
- $this->assertInstanceOf( Ability::class, wp_get_ability( ReadinessAbilities::GET_REPORT ) );
- $meta = wp_get_ability( ReadinessAbilities::GET_REPORT )->get_meta();
+ $this->assertTrue( wp_has_ability( ReportAbility::NAME ) );
+ $this->assertInstanceOf( Ability::class, wp_get_ability( ReportAbility::NAME ) );
+ $this->assertInstanceOf( Ability::class, wp_get_ability( RunCheckAbility::NAME ) );
+ $meta = wp_get_ability( ReportAbility::NAME )->get_meta();
$this->assertTrue( $meta['mcp']['public'] );
$this->assertTrue( $meta['annotations']['readonly'] );
+ $this->assertFalse( wp_get_ability( RunCheckAbility::NAME )->get_meta()['annotations']['idempotent'] );
update_option( 'woocommerce_feature_agent_readiness_enabled', 'no' );
$this->register();
- $this->assertFalse( wp_has_ability( ReadinessAbilities::GET_REPORT ) );
+ $this->assertFalse( wp_has_ability( ReportAbility::NAME ) );
+ $this->assertFalse( wp_has_ability( RunCheckAbility::NAME ) );
}
/**
- * @testdox Only users who can manage WooCommerce can read the report.
+ * @testdox Only users who can manage WooCommerce can read the report or run a check.
*/
public function test_permission(): void {
wp_set_current_user( self::factory()->user->create( array( 'role' => 'shop_manager' ) ) );
- $this->assertTrue( wp_get_ability( ReadinessAbilities::GET_REPORT )->check_permissions() );
+ $this->assertTrue( wp_get_ability( ReportAbility::NAME )->check_permissions() );
wp_set_current_user( self::factory()->user->create( array( 'role' => 'customer' ) ) );
- $this->assertFalse( wp_get_ability( ReadinessAbilities::GET_REPORT )->check_permissions() );
- $this->assertInstanceOf( \WP_Error::class, wp_get_ability( ReadinessAbilities::GET_REPORT )->execute() );
+ $this->assertFalse( wp_get_ability( ReportAbility::NAME )->check_permissions() );
+ $this->assertInstanceOf( \WP_Error::class, wp_get_ability( ReportAbility::NAME )->execute() );
+ $this->assertFalse( wp_get_ability( RunCheckAbility::NAME )->check_permissions( array() ) );
+ $this->assertInstanceOf( \WP_Error::class, wp_get_ability( RunCheckAbility::NAME )->execute() );
}
/**
@@ -129,7 +129,7 @@ class ReadinessAbilitiesTest extends WC_Unit_Test_Case {
*/
public function test_report_matches_output_schema(): void {
delete_option( AgentReadiness::REPORT_OPTION );
- $ability = wp_get_ability( ReadinessAbilities::GET_REPORT );
+ $ability = wp_get_ability( ReportAbility::NAME );
$this->assertNull( $ability->execute()['report'] );
WC_Helper_Product::create_simple_product();
@@ -156,10 +156,74 @@ class ReadinessAbilitiesTest extends WC_Unit_Test_Case {
$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 );
- $output = wp_get_ability( ReadinessAbilities::GET_REPORT )->execute();
+ $output = wp_get_ability( ReportAbility::NAME )->execute();
+
+ $this->assertTrue( $output['scan_pending'] );
+ $this->assertTrue( $output['scan_stalled'] );
+ as_unschedule_all_actions( ScheduledScan::RUN_NOW_HOOK );
+ }
+
+ /**
+ * @testdox Checking a product after a fix returns its new result; a variation is checked as its parent.
+ */
+ public function test_run_check_one_product(): void {
+ $ability = wp_get_ability( RunCheckAbility::NAME );
+ $product = WC_Helper_Product::create_variation_product();
+
+ $before = $ability->execute( array( 'product_id' => $product->get_children()[0] ) );
+ $this->assertTrue( rest_validate_value_from_schema( $before, $ability->get_output_schema() ) );
+ $this->assertSame( $product->get_id(), $before['product']['product_id'] );
+ $this->assertContains( 'missing_brand', array_column( $before['product']['checks'], 'id' ) );
+ $this->assertSame( 'not_listable', $before['product']['statuses']['chatgpt'] );
+
+ wp_set_object_terms( $product->get_id(), 'Acme', 'product_brand' );
+ $after = $ability->execute( array( 'product_id' => $product->get_id() ) );
+ $this->assertNotContains( 'missing_brand', array_column( $after['product']['checks'], 'id' ) );
+ }
+
+ /**
+ * @testdox A product the check leaves out, or an id that is not a product, is an error.
+ */
+ public function test_run_check_product_errors(): void {
+ $ability = wp_get_ability( RunCheckAbility::NAME );
+ $external = WC_Helper_Product::create_external_product();
+
+ $this->assertSame( 'woocommerce_agent_readiness_product_not_checked', $ability->execute( array( 'product_id' => $external->get_id() ) )->get_error_code() );
+ $this->assertSame( 'woocommerce_agent_readiness_product_not_found', $ability->execute( array( 'product_id' => 999999 ) )->get_error_code() );
+ }
+
+ /**
+ * @testdox Without a product the whole store is queued in the background, not checked in the request.
+ */
+ public function test_run_check_queues_scan(): void {
+ delete_option( AgentReadiness::REPORT_OPTION );
+ as_unschedule_all_actions( ScheduledScan::RUN_NOW_HOOK, array(), ScheduledScan::GROUP );
+ $ability = wp_get_ability( RunCheckAbility::NAME );
+
+ $output = $ability->execute();
+
+ $this->assertTrue( rest_validate_value_from_schema( $output, $ability->get_output_schema() ) );
+ $this->assertTrue( $output['scan_pending'] );
+ $this->assertFalse( $output['scan_stalled'] );
+ $this->assertNull( $output['report_generated_at'] );
+ $this->assertNull( get_option( AgentReadiness::REPORT_OPTION, null ), 'Nothing ran in the request' );
+ }
+
+ /**
+ * @testdox Without a product, a queued check that has stalled stays queued and is reported as scan_stalled.
+ */
+ public function test_run_check_says_when_the_scan_stalled(): void {
+ global $wpdb;
+ delete_option( AgentReadiness::REPORT_OPTION );
+ as_unschedule_all_actions( ScheduledScan::RUN_NOW_HOOK );
+ $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 );
+
+ $output = wp_get_ability( RunCheckAbility::NAME )->execute();
$this->assertTrue( $output['scan_pending'] );
$this->assertTrue( $output['scan_stalled'] );
+ $this->assertNull( get_option( AgentReadiness::REPORT_OPTION, null ), 'The stalled check must not run in the request' );
as_unschedule_all_actions( ScheduledScan::RUN_NOW_HOOK );
}
}