Commit 0b863fde9f2 for woocommerce
commit 0b863fde9f2edf0066f19b242009ab692b97a08f
Author: AnnaMag <AnnaMag@users.noreply.github.com>
Date: Mon Nov 9 16:12:56 2026 +0100
Agent readiness: read the checks from a published rules file (#70345)
* Agent readiness: read the checks from a published rules file
Part of #70345.
* Agent readiness: drop a non-string field from the rules file, and say the fetch sends the site URL
A malformed field would become the string "Array" and warn on every store. The user agent already includes the site URL, as other WooCommerce.com polls do.
* 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: keep the last good rules file when a new one has nothing usable
A fetched rules file is stored only when at least one check has a known
channel with a string field and a valid effect and, from the scan, is a
check this code can run. A file like {"checks":{"missing_brand":
{"channels":[]}}} no longer replaces the last good copy and turns every
bundled check back on.
* Agent readiness: validate dated effects before storing the rules file
A channel with a valid effect but a bad effect_from value made the file
look usable, then broke the channel once the date applied, so the bundled
checks ran instead. A channel now counts as usable only when every dated
effect has a Y-m-d date and a valid effect, and reading the file ignores a
bad dated entry instead of applying it.
* Agent readiness: count failed and running actions as queue activity
is_stalled() only counted completed actions, so a queue whose recent
actions failed or were still running was reported as not running.
The REST controller test added its feature filter before parent::setUp(),
so if it ran first the filter was saved in the hook snapshot and later
tests saw the feature forced on.
* Agent readiness: say in the README that failed and running actions count as queue activity
diff --git a/plugins/woocommerce/changelog/add-agent-readiness-remote-rules b/plugins/woocommerce/changelog/add-agent-readiness-remote-rules
new file mode 100644
index 00000000000..1f0570984c8
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-agent-readiness-remote-rules
@@ -0,0 +1,3 @@
+Significance: patch
+Type: dev
+Comment: Experimental agent readiness reads which checks run, and their channel rules, from a published file; behind a hidden flag.
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/AgentReadiness.php b/plugins/woocommerce/src/Internal/AgentReadiness/AgentReadiness.php
index c8382992910..b3cbf181889 100644
--- a/plugins/woocommerce/src/Internal/AgentReadiness/AgentReadiness.php
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/AgentReadiness.php
@@ -37,6 +37,13 @@ class AgentReadiness {
*/
private StoreConditions $store_conditions;
+ /**
+ * Rules from the published rules file.
+ *
+ * @var RemoteRules
+ */
+ private RemoteRules $remote_rules;
+
/**
* Scan reused for single-product checks, so its shared GTIN lookup runs once per request.
*
@@ -57,9 +64,11 @@ class AgentReadiness {
* @internal
*
* @param StoreConditions $store_conditions Store-wide conditions.
+ * @param RemoteRules $remote_rules Rules from the published rules file.
*/
- final public function init( StoreConditions $store_conditions ): void {
+ final public function init( StoreConditions $store_conditions, RemoteRules $remote_rules ): void {
$this->store_conditions = $store_conditions;
+ $this->remote_rules = $remote_rules;
}
/**
@@ -92,16 +101,29 @@ class AgentReadiness {
}
/**
- * Field and effect per channel for each check, after the
- * woocommerce_agent_readiness_rules filter. A check whose id is missing
- * from the filtered array does not run.
+ * Field and effect per channel for each check that runs. With a stored
+ * rules file, the file is the list: an id it leaves out does not run, an
+ * id code does not know is dropped, and an id whose entry is malformed
+ * runs with its bundled channels. With no file, the bundled checks
+ * run. The woocommerce_agent_readiness_rules filter runs last, and a check
+ * whose id is missing from the filtered array does not run.
*
* @return array<string, array<string, array{field:string,effect:string}>>
*/
public function get_channel_rules(): array {
- $defaults = array();
+ $bundled = array();
foreach ( $this->get_default_rules() as $id => $rule ) {
- $defaults[ $id ] = $rule->channels();
+ $bundled[ $id ] = $rule->channels();
+ }
+ if ( $this->remote_rules->has_stored_file() ) {
+ $from_file = array();
+ foreach ( $this->sanitize_channel_rules( $this->remote_rules->channel_rules(), $bundled ) as $id => $channels ) {
+ $from_file[ $id ] = array() !== $channels ? $channels : $bundled[ $id ];
+ }
+ // A file with no check this code can run does not turn the bundled checks off.
+ $defaults = array() !== $from_file ? $from_file : $bundled;
+ } else {
+ $defaults = $bundled;
}
/**
@@ -110,7 +132,8 @@ class AgentReadiness {
* Keys are check ids; values map a channel id (chatgpt, google, copilot)
* to the feed field the check tests there and its effect: not_listable
* when the channel's spec requires the field, gaps when the product
- * lists with a gap. Remove a check id to stop that check running.
+ * lists with a gap. Remove a check id to stop that check running. Add
+ * back an id the rules file left out to run that bundled check.
*
* @since 11.4.0
*
@@ -118,8 +141,7 @@ class AgentReadiness {
*/
$filtered = apply_filters( 'woocommerce_agent_readiness_rules', $defaults );
- // A check left with no valid channel doesn't run, as if the filter had removed it.
- return is_array( $filtered ) ? array_filter( $this->sanitize_channel_rules( $filtered, $defaults ) ) : $defaults;
+ return is_array( $filtered ) ? $this->without_empty( $this->sanitize_channel_rules( $filtered, $bundled ) ) : $defaults;
}
/**
@@ -139,11 +161,12 @@ class AgentReadiness {
}
$rules[ $id ] = array();
foreach ( $rule_channels as $channel => $spec ) {
- if ( ! isset( $channels[ $channel ] ) || ! is_array( $spec ) || ! in_array( $spec['effect'] ?? null, $effects, true ) ) {
+ $field = is_array( $spec ) ? ( $spec['field'] ?? null ) : null;
+ if ( ! isset( $channels[ $channel ] ) || ! is_string( $field ) || ! in_array( $spec['effect'] ?? null, $effects, true ) ) {
continue;
}
$rules[ $id ][ $channel ] = array(
- 'field' => (string) ( $spec['field'] ?? '' ),
+ 'field' => $field,
'effect' => $spec['effect'],
);
}
@@ -151,13 +174,28 @@ class AgentReadiness {
return $rules;
}
+ /**
+ * Drop checks left with no valid channel.
+ *
+ * @param array<string, array<string, array{field:string,effect:string}>> $rules Channel map by check id.
+ * @return array<string, array<string, array{field:string,effect:string}>>
+ */
+ private function without_empty( array $rules ): array {
+ return array_filter(
+ $rules,
+ static function ( array $channels ): bool {
+ return array() !== $channels;
+ }
+ );
+ }
+
/**
* Run every check over the catalog.
*
* @return array{generated_at:string,products:int,hidden_from_catalog:int,unsupported_types:int,store_checks:array<int,array<string,mixed>>,channels:array<string,array<string,mixed>>,checks:array<int,array<string,mixed>>,truncated:bool}
*/
public function get_report(): array {
- // Rebuild the rules and the scan for each report: a long-running process may have changed the rules or GTINs since.
+ // Rebuild the rules and the scan for each report: a long-running process may have fetched a new rules file or changed GTINs since.
$this->active_rules = null;
$this->product_scan = null;
$active = $this->get_active_rules();
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/README.md b/plugins/woocommerce/src/Internal/AgentReadiness/README.md
index d65e71cd25b..e357933d2b9 100644
--- a/plugins/woocommerce/src/Internal/AgentReadiness/README.md
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/README.md
@@ -96,9 +96,19 @@ 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.
+## Rules file
+
+Channel specs change faster than WooCommerce releases, so each background scan first fetches a published rules file (`RemoteRules`). Only a GET of the file is made; the request's user agent includes the site URL, as other WooCommerce.com polls do. The `woocommerce_agent_readiness_rules_url` filter changes the URL; an empty URL turns the fetch off. With no stored file, or a failed fetch, the bundled checks run.
+
+The file has a `version` and a `checks` object. The ids in `checks` are the checks that run, and each one has to match a check shipped in code. An id the file leaves out does not run. An id code does not know, or an unknown channel, is dropped. A check whose entry has a known channel with a missing field, an effect other than `not_listable` or `gaps`, or a bad `effect_from` runs with its bundled channels, so a typo never turns a check off. With no stored file, or a file that names nothing this code can run, the bundled checks run. A failed or malformed fetch keeps the last good copy (option `woocommerce_agent_readiness_remote_rules`).
+
+Per check, `channels` maps a channel id to the feed field and the effect, the same shape as the `woocommerce_agent_readiness_rules` filter. A channel entry may carry `effect_from` (`{ "2027-01-31": "not_listable" }`) for a spec change announced ahead. The filter runs last, so it can still drop a check or put a bundled one back. The file cannot add a check the code has never seen: that still needs a class.
+
+`rules.json` in this directory is the file to publish. It lists every check the code has today, so the first publish changes nothing. Removing an id there turns that check off without a WooCommerce release. It will be served from `https://woocommerce.com/wp-json/wccom/agent-readiness/1.0/rules.json`, the same host as the payment gateway suggestions (`wccom-plugins` on WooCommerce.com).
+
## 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.
+"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, failed or started 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.
@@ -110,9 +120,7 @@ The scan walks published simple and variable products shown in the shop catalog
## Updating rules
-The per-channel field and effect live in each check's `channels()`. The final map passes through the `woocommerce_agent_readiness_rules` filter, keyed by check id, so it can change without a release. Removing a check id stops that check. Unknown channels and effects are dropped.
-
-Next step, out of scope here: fetch the rules map from a remote JSON spec with the bundled `channels()` as the fallback, the way core polls remote specs for payment and marketing suggestions with bundled defaults.
+The per-channel field and effect live in each check's `channels()`, and that is what runs until a rules file is stored. After that, `rules.json` is the list. The `woocommerce_agent_readiness_rules` filter still runs last; unknown channels and effects in what it returns are dropped.
## Product feeds
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/RemoteRules.php b/plugins/woocommerce/src/Internal/AgentReadiness/RemoteRules.php
new file mode 100644
index 00000000000..67891915985
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/RemoteRules.php
@@ -0,0 +1,203 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\AgentReadiness;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Channel rules fetched from a published rules file, so a channel's spec
+ * change can reach stores without a WooCommerce release.
+ *
+ * The file is the list of checks that run. Each id has to match a check in
+ * code; an id the file leaves out does not run, and an id code does not know
+ * is ignored. A check whose entry has a malformed channel runs with its
+ * bundled channels instead.
+ *
+ * The file is fetched before each background scan: the daily run and any
+ * scan that is asked for. Only a GET is made; the request's user agent
+ * includes the site URL, as other WooCommerce.com polls do. A failed fetch
+ * keeps the last good copy, and with no copy the bundled checks apply.
+ *
+ * The file is fetched from PUBLISHED_URL, the same host as the payment gateway
+ * suggestions; the woocommerce_agent_readiness_rules_url filter changes it.
+ *
+ * @since 11.4.0
+ */
+class RemoteRules {
+
+ /**
+ * Option holding the last good rules file.
+ */
+ public const OPTION = 'woocommerce_agent_readiness_remote_rules';
+
+ /**
+ * Default rules file URL. While it answers with an error, such as a 404, the bundled checks run.
+ */
+ public const PUBLISHED_URL = 'https://woocommerce.com/wp-json/wccom/agent-readiness/1.0/rules.json';
+
+ /**
+ * Fetch the rules file and store it when at least one check entry is valid
+ * (see is_valid_check()) and, when known ids are given, is one of them.
+ * Anything less keeps the last good copy.
+ *
+ * @param array<int, string>|null $known_ids Check ids the code can run; null accepts any id.
+ * @return bool Whether a new copy was stored.
+ */
+ public function refresh( ?array $known_ids = null ): bool {
+ /**
+ * Filters the URL of the agent readiness rules file. Empty means the bundled checks only.
+ *
+ * @since 11.4.0
+ *
+ * @param string $url Rules file URL.
+ */
+ $url = apply_filters( 'woocommerce_agent_readiness_rules_url', self::PUBLISHED_URL );
+ if ( ! is_string( $url ) || '' === $url ) {
+ return false;
+ }
+
+ $response = wp_safe_remote_get(
+ $url,
+ array(
+ 'timeout' => 5,
+ 'limit_response_size' => 256 * KB_IN_BYTES,
+ )
+ );
+ if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
+ return false;
+ }
+ $rules = json_decode( wp_remote_retrieve_body( $response ), true );
+ if ( ! is_array( $rules ) || ! isset( $rules['checks'] ) || ! is_array( $rules['checks'] ) ) {
+ return false;
+ }
+
+ // A malformed entry is kept with no channels, so its check runs with the bundled ones rather than not at all.
+ $checks = array();
+ foreach ( $rules['checks'] as $id => $check ) {
+ if ( is_string( $id ) ) {
+ $checks[ $id ] = is_array( $check ) && isset( $check['channels'] ) && is_array( $check['channels'] ) ? $check['channels'] : array();
+ }
+ }
+ // Nothing this code could run. Do not replace the last good copy with it.
+ $usable = array_filter(
+ $checks,
+ static fn( array $channels, string $id ): bool => ( null === $known_ids || in_array( $id, $known_ids, true ) ) && self::is_valid_check( $channels ),
+ ARRAY_FILTER_USE_BOTH
+ );
+ if ( array() === $usable ) {
+ return false;
+ }
+ update_option(
+ self::OPTION,
+ array(
+ 'version' => (string) ( $rules['version'] ?? '' ),
+ 'checks' => $checks,
+ ),
+ false
+ );
+ return true;
+ }
+
+ /**
+ * Whether a check's channel map names at least one known channel, and every
+ * known channel has a string field, a valid effect and, when effect_from is
+ * present, only Y-m-d dates with valid effects. Unknown channels are ignored.
+ *
+ * @param array<mixed> $channels Channel map from the file.
+ */
+ private static function is_valid_check( array $channels ): bool {
+ $known = array_intersect_key( $channels, FeedChannels::all() );
+ foreach ( $known as $spec ) {
+ if ( ! is_array( $spec ) || ! is_string( $spec['field'] ?? null ) || ! self::is_effect( $spec['effect'] ?? null ) ) {
+ return false;
+ }
+ $dates = array_key_exists( 'effect_from', $spec ) ? $spec['effect_from'] : array();
+ if ( ! is_array( $dates ) || count( $dates ) !== count( self::valid_dates( $dates ) ) ) {
+ return false;
+ }
+ }
+ return array() !== $known;
+ }
+
+ /**
+ * Whether a value is an effect the code applies.
+ *
+ * @param mixed $effect Effect from the file.
+ */
+ private static function is_effect( $effect ): bool {
+ return in_array( $effect, array( ReadinessRule::EFFECT_NOT_LISTABLE, ReadinessRule::EFFECT_GAPS ), true );
+ }
+
+ /**
+ * The effect_from entries keyed by a Y-m-d date with a valid effect, in date order.
+ *
+ * @param array<mixed> $dates effect_from map from the file.
+ * @return array<string, string>
+ */
+ private static function valid_dates( array $dates ): array {
+ $valid = array();
+ foreach ( $dates as $from => $effect ) {
+ if ( 1 === preg_match( '/^\d{4}-\d{2}-\d{2}$/', (string) $from ) && self::is_effect( $effect ) ) {
+ $valid[ (string) $from ] = $effect;
+ }
+ }
+ ksort( $valid );
+ return $valid;
+ }
+
+ /**
+ * Whether a rules file is stored. A stored file whose checks the code does
+ * not know is not used: the bundled checks run instead.
+ */
+ public function has_stored_file(): bool {
+ $stored = get_option( self::OPTION );
+ return is_array( $stored ) && isset( $stored['checks'] ) && is_array( $stored['checks'] );
+ }
+
+ /**
+ * Channel maps by check id from the stored file, with each effect_from
+ * date already applied for the given day. A check whose entry is not
+ * valid (see is_valid_check()) maps to an empty array, so the bundled
+ * channels run for it. Empty when nothing is stored.
+ *
+ * @param int|null $now Unix time to resolve dates for; defaults to now.
+ * @return array<string, array<string, array{field:string,effect:string}>>
+ */
+ public function channel_rules( ?int $now = null ): array {
+ $stored = get_option( self::OPTION );
+ if ( ! is_array( $stored ) || ! isset( $stored['checks'] ) || ! is_array( $stored['checks'] ) ) {
+ return array();
+ }
+
+ $today = gmdate( 'Y-m-d', $now ?? time() );
+ $rules = array();
+ foreach ( $stored['checks'] as $id => $channels ) {
+ $rules[ $id ] = array();
+ if ( ! is_array( $channels ) || ! self::is_valid_check( $channels ) ) {
+ continue;
+ }
+ foreach ( array_intersect_key( $channels, FeedChannels::all() ) as $channel => $spec ) {
+ $effect = $spec['effect'];
+ foreach ( self::valid_dates( $spec['effect_from'] ?? array() ) as $from => $later ) {
+ if ( $today >= $from ) {
+ $effect = $later;
+ }
+ }
+ $rules[ $id ][ $channel ] = array(
+ 'field' => $spec['field'],
+ 'effect' => $effect,
+ );
+ }
+ }
+ return $rules;
+ }
+
+ /**
+ * Version of the stored rules file, or '' when none is stored.
+ */
+ public function version(): string {
+ $stored = get_option( self::OPTION );
+ return is_array( $stored ) ? (string) ( $stored['version'] ?? '' ) : '';
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/ScheduledScan.php b/plugins/woocommerce/src/Internal/AgentReadiness/ScheduledScan.php
index b93c086d6cd..4845fdf82e4 100644
--- a/plugins/woocommerce/src/Internal/AgentReadiness/ScheduledScan.php
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/ScheduledScan.php
@@ -59,6 +59,13 @@ class ScheduledScan implements RegisterHooksInterface {
*/
private AgentReadiness $agent_readiness;
+ /**
+ * Rules from the published rules file.
+ *
+ * @var RemoteRules
+ */
+ private RemoteRules $remote_rules;
+
/**
* Initialize dependencies.
*
@@ -66,10 +73,12 @@ class ScheduledScan implements RegisterHooksInterface {
*
* @param FeaturesController $features_controller Features controller.
* @param AgentReadiness $agent_readiness Readiness service.
+ * @param RemoteRules $remote_rules Rules from the published rules file.
*/
- final public function init( FeaturesController $features_controller, AgentReadiness $agent_readiness ): void {
+ final public function init( FeaturesController $features_controller, AgentReadiness $agent_readiness, RemoteRules $remote_rules ): void {
$this->features_controller = $features_controller;
$this->agent_readiness = $agent_readiness;
+ $this->remote_rules = $remote_rules;
}
/**
@@ -115,8 +124,8 @@ class ScheduledScan implements RegisterHooksInterface {
/**
* 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.
+ * after it was due, and no other action has finished, failed or started 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' ) ) {
@@ -127,20 +136,20 @@ class ScheduledScan implements RegisterHooksInterface {
if ( ! is_int( $due ) || time() - $due <= self::STALLED_AFTER ) {
return false;
}
- $recently_finished = as_get_scheduled_actions(
+ $recently_run = as_get_scheduled_actions(
array(
- 'status' => \ActionScheduler_Store::STATUS_COMPLETE,
+ 'status' => array( \ActionScheduler_Store::STATUS_COMPLETE, \ActionScheduler_Store::STATUS_FAILED, \ActionScheduler_Store::STATUS_RUNNING ),
'modified' => new \DateTime( '@' . ( time() - self::STALLED_AFTER ) ),
'modified_compare' => '>=',
'per_page' => 1,
),
'ids'
);
- return empty( $recently_finished );
+ return empty( $recently_run );
}
/**
- * Schedule the next daily run, then run the scan and store the report.
+ * Schedule the next daily run, fetch the rules file, then run the scan and store the report.
*
* @internal
*/
@@ -151,6 +160,7 @@ class ScheduledScan implements RegisterHooksInterface {
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->remote_rules->refresh( array_keys( $this->agent_readiness->get_default_rules() ) );
$this->agent_readiness->refresh_report();
}
}
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/rules.json b/plugins/woocommerce/src/Internal/AgentReadiness/rules.json
new file mode 100644
index 00000000000..20415bf9101
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/rules.json
@@ -0,0 +1,89 @@
+{
+ "version": "1",
+ "checks": {
+ "missing_regular_price": {
+ "channels": {
+ "chatgpt": { "field": "price", "effect": "not_listable" },
+ "google": { "field": "price", "effect": "not_listable" },
+ "copilot": { "field": "price", "effect": "not_listable" }
+ }
+ },
+ "missing_images": {
+ "channels": {
+ "chatgpt": { "field": "image_url", "effect": "not_listable" },
+ "google": { "field": "image_link", "effect": "not_listable" },
+ "copilot": { "field": "imageLink", "effect": "not_listable" }
+ }
+ },
+ "missing_product_text": {
+ "channels": {
+ "chatgpt": { "field": "description", "effect": "not_listable" },
+ "google": { "field": "description", "effect": "gaps" },
+ "copilot": { "field": "description", "effect": "gaps" }
+ }
+ },
+ "missing_brand": {
+ "channels": {
+ "chatgpt": { "field": "brand", "effect": "not_listable" },
+ "google": { "field": "brand", "effect": "gaps" },
+ "copilot": { "field": "brand", "effect": "gaps" }
+ }
+ },
+ "invalid_gtin": {
+ "channels": {
+ "chatgpt": { "field": "gtin", "effect": "gaps" },
+ "google": { "field": "gtin", "effect": "not_listable" },
+ "copilot": { "field": "gtin", "effect": "gaps" }
+ }
+ },
+ "missing_gtin": {
+ "channels": {
+ "chatgpt": { "field": "gtin", "effect": "gaps" },
+ "google": { "field": "gtin", "effect": "gaps" },
+ "copilot": { "field": "gtin", "effect": "gaps" }
+ }
+ },
+ "duplicate_gtin": {
+ "channels": {
+ "google": { "field": "gtin", "effect": "gaps" }
+ }
+ },
+ "image_too_small": {
+ "channels": {
+ "google": {
+ "field": "image_link",
+ "effect": "gaps",
+ "effect_from": { "2027-01-31": "not_listable" }
+ }
+ }
+ },
+ "uncategorised": {
+ "channels": {
+ "chatgpt": { "field": "product_category", "effect": "gaps" },
+ "google": { "field": "product_type", "effect": "gaps" },
+ "copilot": { "field": "productType", "effect": "gaps" }
+ }
+ },
+ "variation_attributes_not_set": {
+ "channels": {
+ "chatgpt": { "field": "variant_dict", "effect": "gaps" },
+ "google": { "field": "color, size", "effect": "gaps" },
+ "copilot": { "field": "color, sizes", "effect": "gaps" }
+ }
+ },
+ "missing_variation_images": {
+ "channels": {
+ "chatgpt": { "field": "image_url", "effect": "gaps" },
+ "google": { "field": "image_link", "effect": "gaps" },
+ "copilot": { "field": "imageLink", "effect": "gaps" }
+ }
+ },
+ "title_too_long": {
+ "channels": {
+ "chatgpt": { "field": "title", "effect": "gaps" },
+ "google": { "field": "title", "effect": "gaps" },
+ "copilot": { "field": "title", "effect": "gaps" }
+ }
+ }
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/AgentReadinessTest.php b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/AgentReadinessTest.php
index a155787b059..248d2e4fbaf 100644
--- a/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/AgentReadinessTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/AgentReadinessTest.php
@@ -5,6 +5,7 @@ namespace Automattic\WooCommerce\Tests\Internal\AgentReadiness;
use Automattic\WooCommerce\Internal\AgentReadiness\AgentReadiness;
use Automattic\WooCommerce\Internal\AgentReadiness\ReadinessRule;
+use Automattic\WooCommerce\Internal\AgentReadiness\RemoteRules;
use Automattic\WooCommerce\Internal\AgentReadiness\StoreConditions;
use Automattic\WooCommerce\Internal\ComingSoon\ComingSoonHelper;
use WC_Helper_Product;
@@ -30,7 +31,7 @@ class AgentReadinessTest extends WC_Unit_Test_Case {
$store_conditions = new StoreConditions();
$store_conditions->init( new ComingSoonHelper() );
$this->sut = new AgentReadiness();
- $this->sut->init( $store_conditions );
+ $this->sut->init( $store_conditions, new RemoteRules() );
}
/**
diff --git a/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/ReadinessRestControllerTest.php b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/ReadinessRestControllerTest.php
index f3ad1a143c0..e760b86a23a 100644
--- a/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/ReadinessRestControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/ReadinessRestControllerTest.php
@@ -14,12 +14,12 @@ use WP_REST_Request;
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().
+ * Enable the feature after parent::setUp(), so the filter stays out of the hook
+ * snapshot WordPress takes in the first test. Routes register on first dispatch.
*/
public function setUp(): void {
- add_filter( 'pre_option_woocommerce_feature_agent_readiness_enabled', array( __CLASS__, 'enabled' ) );
parent::setUp();
+ add_filter( 'pre_option_woocommerce_feature_agent_readiness_enabled', array( __CLASS__, 'enabled' ) );
delete_option( AgentReadiness::REPORT_OPTION );
as_unschedule_all_actions( ScheduledScan::RUN_NOW_HOOK );
wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
@@ -74,7 +74,7 @@ class ReadinessRestControllerTest extends WC_REST_Unit_Test_Case {
*/
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 ) ) );
+ $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->prefix}actionscheduler_actions SET last_attempt_gmt = %s WHERE status IN ( 'complete', 'failed', 'in-progress' )", 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' ) );
diff --git a/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/RemoteRulesTest.php b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/RemoteRulesTest.php
new file mode 100644
index 00000000000..c85312fd9be
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/RemoteRulesTest.php
@@ -0,0 +1,629 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\AgentReadiness;
+
+use Automattic\WooCommerce\Internal\AgentReadiness\AgentReadiness;
+use Automattic\WooCommerce\Internal\AgentReadiness\RemoteRules;
+use Automattic\WooCommerce\Internal\AgentReadiness\StoreConditions;
+use Automattic\WooCommerce\Internal\ComingSoon\ComingSoonHelper;
+use WC_Helper_Product;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the published agent readiness rules file.
+ */
+class RemoteRulesTest extends WC_Unit_Test_Case {
+
+ private const URL = 'https://rules.example/agent-readiness.json';
+
+ /**
+ * The System Under Test.
+ *
+ * @var RemoteRules
+ */
+ private $sut;
+
+ /**
+ * Requests made through the HTTP API.
+ *
+ * @var array<int, string>
+ */
+ private array $requests = array();
+
+ /**
+ * Body and status the next request answers with.
+ *
+ * @var array{body:string,code:int}
+ */
+ private array $answer = array(
+ 'body' => '',
+ 'code' => 200,
+ );
+
+ /**
+ * Set up test fixtures.
+ */
+ public function setUp(): void {
+ parent::setUp();
+ delete_option( RemoteRules::OPTION );
+ $this->sut = new RemoteRules();
+ add_filter( 'pre_http_request', array( $this, 'answer_request' ), 10, 3 );
+ }
+
+ /**
+ * Tear down test fixtures.
+ */
+ public function tearDown(): void {
+ remove_filter( 'pre_http_request', array( $this, 'answer_request' ), 10 );
+ delete_option( RemoteRules::OPTION );
+ parent::tearDown();
+ }
+
+ /**
+ * Record the request and answer with the prepared response.
+ *
+ * @param mixed $pre Short-circuit value.
+ * @param array $args Request arguments.
+ * @param string $url Request URL.
+ * @return array
+ */
+ public function answer_request( $pre, $args, $url ) {
+ $this->requests[] = $url;
+ return array(
+ 'headers' => array(),
+ 'body' => $this->answer['body'],
+ 'response' => array(
+ 'code' => $this->answer['code'],
+ 'message' => '',
+ ),
+ 'cookies' => array(),
+ );
+ }
+
+ /**
+ * Point the rules URL at the test URL and answer with this rules file.
+ *
+ * @param array $checks Channel maps by check id.
+ */
+ private function serve( array $checks ): void {
+ add_filter(
+ 'woocommerce_agent_readiness_rules_url',
+ static function () {
+ return self::URL;
+ }
+ );
+ $file = array(
+ 'version' => '2026-11-01',
+ 'checks' => array(),
+ );
+ foreach ( $checks as $id => $channels ) {
+ $file['checks'][ $id ] = array(
+ 'label' => 'Ignored by core',
+ 'channels' => $channels,
+ );
+ }
+ $this->answer = array(
+ 'body' => (string) wp_json_encode( $file ),
+ 'code' => 200,
+ );
+ }
+
+ /**
+ * A report service that reads the stored rules file.
+ */
+ private function agent_readiness(): AgentReadiness {
+ $store_conditions = new StoreConditions();
+ $store_conditions->init( new ComingSoonHelper() );
+ $agent_readiness = new AgentReadiness();
+ $agent_readiness->init( $store_conditions, $this->sut );
+ return $agent_readiness;
+ }
+
+ /**
+ * @testdox With no URL set nothing is fetched and the bundled rules apply.
+ */
+ public function test_no_url_makes_no_request(): void {
+ add_filter( 'woocommerce_agent_readiness_rules_url', '__return_empty_string' );
+ $this->assertFalse( $this->sut->refresh() );
+ $this->assertSame( array(), $this->requests );
+ $this->assertSame( array(), $this->sut->channel_rules() );
+ $this->assertArrayHasKey( 'missing_images', $this->agent_readiness()->get_channel_rules(), 'With no file, the bundled checks run' );
+ }
+
+ /**
+ * @testdox A fetched rules file replaces a check's bundled channel map in the report.
+ */
+ public function test_fetched_rules_change_the_report(): void {
+ $this->serve(
+ array(
+ 'missing_brand' => array(
+ 'google' => array(
+ 'field' => 'brand',
+ 'effect' => 'not_listable',
+ ),
+ ),
+ )
+ );
+ $this->assertTrue( $this->sut->refresh() );
+ $this->assertSame( array( self::URL ), $this->requests );
+ $this->assertSame( '2026-11-01', $this->sut->version() );
+
+ WC_Helper_Product::create_simple_product();
+ $report = $this->agent_readiness()->get_report();
+ $checks = array_column( $report['checks'], null, 'id' );
+
+ $this->assertSame( array( 'google' ), array_keys( $checks['missing_brand']['channels'] ), 'The file\'s map replaces the bundled one' );
+ $this->assertSame( 1, $report['channels']['google']['not_listable'] );
+ $this->assertArrayNotHasKey( 'missing_images', $checks, 'A check the file leaves out does not run' );
+ }
+
+ /**
+ * @testdox An effect_from date switches the effect from that day on.
+ */
+ public function test_effect_from_date(): void {
+ $this->serve(
+ array(
+ 'image_too_small' => array(
+ 'google' => array(
+ 'field' => 'image_link',
+ 'effect' => 'gaps',
+ 'effect_from' => array( '2027-01-31' => 'not_listable' ),
+ ),
+ ),
+ )
+ );
+ $this->sut->refresh();
+
+ $this->assertSame( 'gaps', $this->sut->channel_rules( strtotime( '2027-01-30' ) )['image_too_small']['google']['effect'] );
+ $this->assertSame( 'not_listable', $this->sut->channel_rules( strtotime( '2027-01-31' ) )['image_too_small']['google']['effect'] );
+ }
+
+ /**
+ * @testdox A check entry with a malformed channels map, channel or dated effect runs with its bundled channels, while the file's valid checks apply.
+ */
+ public function test_malformed_check_entry_uses_bundled_channels(): void {
+ $this->serve(
+ array(
+ 'title_too_long' => array(
+ 'google' => array(
+ 'field' => 'title',
+ 'effect' => 'gaps',
+ ),
+ ),
+ 'missing_brand' => array(
+ 'google' => array(
+ 'field' => 'brand',
+ 'effect' => 'sometimes',
+ ),
+ ),
+ 'image_too_small' => array(
+ 'google' => array(
+ 'field' => 'image_link',
+ 'effect' => 'gaps',
+ 'effect_from' => array( '2027-1-31' => 'not_listable' ),
+ ),
+ 'chatgpt' => array(
+ 'field' => 'image_link',
+ 'effect' => 'gaps',
+ ),
+ ),
+ )
+ );
+ $this->answer['body'] = (string) wp_json_encode(
+ array_merge_recursive(
+ json_decode( $this->answer['body'], true ),
+ array(
+ 'checks' => array(
+ 'missing_gtin' => array( 'channel' => array() ),
+ 'missing_images' => 'google',
+ ),
+ )
+ )
+ );
+ $this->assertTrue( $this->sut->refresh() );
+
+ $agent_readiness = $this->agent_readiness();
+ $defaults = $agent_readiness->get_default_rules();
+ $rules = $agent_readiness->get_channel_rules();
+
+ $this->assertSame( $defaults['missing_gtin']->channels(), $rules['missing_gtin'], 'A misspelled channels key runs the bundled channels' );
+ $this->assertSame( $defaults['missing_images']->channels(), $rules['missing_images'], 'A non-object entry runs the bundled channels' );
+ unset( $rules['missing_gtin'], $rules['missing_images'] );
+ $ids = array_keys( $rules );
+ sort( $ids );
+ $this->assertSame( array( 'image_too_small', 'missing_brand', 'title_too_long' ), $ids, 'Only the file\'s checks run' );
+ $this->assertSame( $defaults['missing_brand']->channels(), $rules['missing_brand'], 'A bad effect runs the bundled channels' );
+ $this->assertSame( $defaults['image_too_small']->channels(), $rules['image_too_small'], 'A bad date runs the bundled channels' );
+ $this->assertSame( array( 'google' ), array_keys( $rules['title_too_long'] ), 'The valid entry still applies' );
+ }
+
+ /**
+ * @testdox A failed fetch before any copy is stored is reported as a failure, and the bundled checks run.
+ */
+ public function test_failed_fetch_with_no_copy_uses_bundled_checks(): void {
+ add_filter(
+ 'woocommerce_agent_readiness_rules_url',
+ static function () {
+ return self::URL;
+ }
+ );
+ $this->answer = array(
+ 'body' => 'Not found',
+ 'code' => 404,
+ );
+
+ $this->assertFalse( $this->sut->refresh(), 'The failed sync is visible to the caller' );
+ $this->assertFalse( $this->sut->has_stored_file() );
+ $this->assertArrayHasKey( 'missing_images', $this->agent_readiness()->get_channel_rules(), 'The bundled checks still run' );
+ }
+
+ /**
+ * @testdox A failed or malformed fetch keeps the last good copy.
+ */
+ public function test_bad_fetch_keeps_last_copy(): void {
+ $this->serve(
+ array(
+ 'title_too_long' => array(
+ 'google' => array(
+ 'field' => 'title',
+ 'effect' => 'not_listable',
+ ),
+ ),
+ )
+ );
+ $this->sut->refresh();
+
+ $this->answer = array(
+ 'body' => 'Not found',
+ 'code' => 404,
+ );
+ $this->assertFalse( $this->sut->refresh() );
+ $this->answer = array(
+ 'body' => '{"version":"x"}',
+ 'code' => 200,
+ );
+ $this->assertFalse( $this->sut->refresh() );
+ $this->answer = array(
+ 'body' => '<html><body>Maintenance</body></html>',
+ 'code' => 200,
+ );
+ $this->assertFalse( $this->sut->refresh() );
+
+ $this->assertSame( 'not_listable', $this->sut->channel_rules()['title_too_long']['google']['effect'] );
+ }
+
+ /**
+ * @testdox With no URL filter the published rules file is fetched, and while it is not served the bundled checks run.
+ */
+ public function test_default_url_is_the_published_file(): void {
+ $this->answer = array(
+ 'body' => 'Not found',
+ 'code' => 404,
+ );
+
+ $this->assertFalse( $this->sut->refresh() );
+ $this->assertSame( array( RemoteRules::PUBLISHED_URL ), $this->requests );
+ $this->assertFalse( $this->sut->has_stored_file() );
+ $this->assertArrayHasKey( 'missing_images', $this->agent_readiness()->get_channel_rules(), 'The bundled checks still run' );
+ }
+
+ /**
+ * @testdox A network error stores nothing, and the bundled checks run.
+ */
+ public function test_network_error_uses_bundled_checks(): void {
+ add_filter(
+ 'woocommerce_agent_readiness_rules_url',
+ static function () {
+ return self::URL;
+ }
+ );
+ $fail = static function () {
+ return new \WP_Error( 'http_request_failed', 'Operation timed out' );
+ };
+ add_filter( 'pre_http_request', $fail, 20 );
+
+ $this->assertFalse( $this->sut->refresh() );
+ remove_filter( 'pre_http_request', $fail, 20 );
+
+ $this->assertSame( array(), $this->sut->channel_rules() );
+ $this->assertArrayHasKey( 'missing_images', $this->agent_readiness()->get_channel_rules(), 'The bundled checks still run' );
+ }
+
+ /**
+ * @testdox Unknown checks and channels in the file never reach the rules filter, an entry with a bad effect runs its bundled channels, and the filter still has the last word.
+ */
+ public function test_file_is_sanitized_and_filter_wins(): void {
+ $this->serve(
+ array(
+ 'not_a_check' => array(
+ 'google' => array(
+ 'field' => 'x',
+ 'effect' => 'not_listable',
+ ),
+ ),
+ 'title_too_long' => array(
+ 'google' => array(
+ 'field' => 'title',
+ 'effect' => 'rejected',
+ ),
+ 'bing' => array(
+ 'field' => 'title',
+ 'effect' => 'gaps',
+ ),
+ ),
+ 'missing_gtin' => array(
+ 'google' => array(
+ 'field' => 'gtin',
+ 'effect' => 'not_listable',
+ ),
+ ),
+ )
+ );
+ $this->sut->refresh();
+ $seen = array();
+ add_filter(
+ 'woocommerce_agent_readiness_rules',
+ static function ( $rules ) use ( &$seen ) {
+ $seen = $rules;
+
+ $rules['missing_gtin']['google']['effect'] = 'gaps';
+ return $rules;
+ }
+ );
+
+ $rules = $this->agent_readiness()->get_channel_rules();
+
+ $bundled = $this->agent_readiness()->get_default_rules()['title_too_long']->channels();
+ $this->assertArrayNotHasKey( 'not_a_check', $seen );
+ $this->assertSame( $bundled, $seen['title_too_long'], 'A malformed entry runs the bundled channels' );
+ $this->assertSame( 'not_listable', $seen['missing_gtin']['google']['effect'], 'The filter sees the file\'s valid values' );
+ $this->assertSame( $bundled, $rules['title_too_long'] );
+ $this->assertSame( 'gaps', $rules['missing_gtin']['google']['effect'] );
+ }
+
+ /**
+ * @testdox A channel whose field is missing or not a string makes its check run with the bundled channels, so a malformed file does not warn.
+ */
+ public function test_non_string_field_uses_bundled_channels(): void {
+ $this->serve(
+ array(
+ 'title_too_long' => array(
+ 'google' => array(
+ 'field' => array( 'title' ),
+ 'effect' => 'gaps',
+ ),
+ 'chatgpt' => array(
+ 'field' => 'title',
+ 'effect' => 'gaps',
+ ),
+ ),
+ 'missing_gtin' => array(
+ 'copilot' => array(
+ 'effect' => 'gaps',
+ ),
+ ),
+ 'missing_brand' => array(
+ 'google' => array(
+ 'field' => 'brand',
+ 'effect' => 'gaps',
+ ),
+ ),
+ )
+ );
+ $this->assertTrue( $this->sut->refresh() );
+
+ $agent_readiness = $this->agent_readiness();
+ $defaults = $agent_readiness->get_default_rules();
+ $rules = $agent_readiness->get_channel_rules();
+
+ $this->assertSame( $defaults['title_too_long']->channels(), $rules['title_too_long'] );
+ $this->assertSame( $defaults['missing_gtin']->channels(), $rules['missing_gtin'] );
+ }
+
+ /**
+ * @testdox A report after a new rules file uses the new rules, even from the same instance.
+ */
+ public function test_report_reads_rules_fetched_after_an_earlier_report(): void {
+ WC_Helper_Product::create_simple_product();
+ $agent_readiness = $this->agent_readiness();
+ $brand_channels = static function ( array $report ): array {
+ return array_column( $report['checks'], 'channels', 'id' )['missing_brand'];
+ };
+ $this->assertArrayHasKey( 'chatgpt', $brand_channels( $agent_readiness->get_report() ) );
+
+ $this->serve(
+ array(
+ 'missing_brand' => array(
+ 'google' => array(
+ 'field' => 'brand',
+ 'effect' => 'not_listable',
+ ),
+ ),
+ )
+ );
+ $this->sut->refresh();
+
+ $this->assertSame( array( 'google' ), array_keys( $brand_channels( $agent_readiness->get_report() ) ) );
+ }
+
+ /**
+ * @testdox A file that names nothing this code can run does not turn the bundled checks off.
+ */
+ public function test_file_with_no_known_check_keeps_bundled_checks(): void {
+ $this->serve(
+ array(
+ 'not_a_check' => array(
+ 'google' => array(
+ 'field' => 'x',
+ 'effect' => 'not_listable',
+ ),
+ ),
+ )
+ );
+
+ $this->assertTrue( $this->sut->refresh() );
+ $this->assertArrayHasKey( 'missing_images', $this->agent_readiness()->get_channel_rules() );
+ }
+
+ /**
+ * @testdox A file with no shaped checks is a failed sync and does not replace the stored copy.
+ */
+ public function test_file_with_no_shaped_checks_is_not_stored(): void {
+ $this->serve(
+ array(
+ 'title_too_long' => array(
+ 'google' => array(
+ 'field' => 'title',
+ 'effect' => 'not_listable',
+ ),
+ ),
+ )
+ );
+ $this->assertTrue( $this->sut->refresh() );
+
+ $this->answer = array(
+ 'body' => '{"version":"x","checks":{"broken":"nope"}}',
+ 'code' => 200,
+ );
+
+ $this->assertFalse( $this->sut->refresh() );
+ $this->assertSame( 'not_listable', $this->sut->channel_rules()['title_too_long']['google']['effect'] );
+ }
+
+ /**
+ * @testdox A file whose checks have no usable channel keeps the last good copy, so the active checks and their effects stay.
+ */
+ public function test_file_with_no_usable_channel_keeps_last_copy(): void {
+ $this->serve(
+ array(
+ 'missing_brand' => array(
+ 'google' => array(
+ 'field' => 'brand',
+ 'effect' => 'not_listable',
+ ),
+ ),
+ )
+ );
+ $this->assertTrue( $this->sut->refresh() );
+ $before = $this->agent_readiness()->get_channel_rules();
+ $this->assertSame( array( 'missing_brand' ), array_keys( $before ) );
+
+ foreach (
+ array(
+ array(),
+ array( 'google' => array() ),
+ array(
+ 'google' => array(
+ 'field' => 'brand',
+ 'effect' => 'sometimes',
+ ),
+ ),
+ array(
+ 'not_a_channel' => array(
+ 'field' => 'brand',
+ 'effect' => 'not_listable',
+ ),
+ ),
+ array(
+ 'google' => array(
+ 'field' => 'brand',
+ 'effect' => 'not_listable',
+ 'effect_from' => array( '2020-01-01' => 'typo' ),
+ ),
+ ),
+ array(
+ 'google' => array(
+ 'field' => 'brand',
+ 'effect' => 'not_listable',
+ 'effect_from' => array( 'soon' => 'gaps' ),
+ ),
+ ),
+ array(
+ 'google' => array(
+ 'field' => 'brand',
+ 'effect' => 'not_listable',
+ 'effect_from' => 'gaps',
+ ),
+ ),
+ array(
+ 'google' => array(
+ 'field' => 'brand',
+ 'effect' => 'not_listable',
+ 'effect_from' => null,
+ ),
+ ),
+ array(
+ 'google' => array(
+ 'field' => 'brand',
+ 'effect' => 'sometimes',
+ ),
+ 'chatgpt' => array(
+ 'field' => 'brand',
+ 'effect' => 'gaps',
+ ),
+ ),
+ ) as $channels
+ ) {
+ $this->serve( array( 'missing_brand' => $channels ) );
+ $this->assertFalse( $this->sut->refresh(), 'An unusable file is not stored: ' . wp_json_encode( $channels ) );
+ }
+
+ $this->assertSame( $before, $this->agent_readiness()->get_channel_rules() );
+ $this->assertSame( 'not_listable', $before['missing_brand']['google']['effect'] );
+ }
+
+ /**
+ * @testdox Given the ids the code can run, a file naming only unknown checks keeps the last good copy.
+ */
+ public function test_file_with_only_unknown_checks_keeps_last_copy(): void {
+ $known = array_keys( $this->agent_readiness()->get_default_rules() );
+ $this->serve(
+ array(
+ 'title_too_long' => array(
+ 'google' => array(
+ 'field' => 'title',
+ 'effect' => 'not_listable',
+ ),
+ ),
+ )
+ );
+ $this->assertTrue( $this->sut->refresh( $known ) );
+
+ $this->serve(
+ array(
+ 'not_a_check' => array(
+ 'google' => array(
+ 'field' => 'x',
+ 'effect' => 'not_listable',
+ ),
+ ),
+ )
+ );
+
+ $this->assertFalse( $this->sut->refresh( $known ) );
+ $this->assertSame( array( 'title_too_long' ), array_keys( $this->sut->channel_rules() ) );
+ }
+
+ /**
+ * @testdox The shipped rules file names the same checks the code can run, and those are the ones that run.
+ */
+ public function test_shipped_rules_file_is_a_subset_of_the_code(): void {
+ $path = dirname( __DIR__, 5 ) . '/src/Internal/AgentReadiness/rules.json';
+ $file = json_decode( (string) file_get_contents( $path ), true ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local file.
+ $this->assertIsArray( $file );
+ $this->assertIsArray( $file['checks'] );
+
+ $known = array_keys( ( new AgentReadiness() )->get_default_rules() );
+ $checks = array();
+ foreach ( $file['checks'] as $id => $check ) {
+ $this->assertContains( $id, $known, 'The file names a check the code does not have' );
+ $checks[ $id ] = $check['channels'];
+ }
+ $this->assertSame( $known, array_keys( $checks ), 'A check in code that is missing from rules.json would not run once a store has fetched the file' );
+
+ $this->serve( $checks );
+ $this->sut->refresh();
+ $this->assertSame( array_keys( $checks ), array_keys( $this->agent_readiness()->get_channel_rules() ) );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/ScheduledScanTest.php b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/ScheduledScanTest.php
index 799b3b3d9ed..afb44b7ca1d 100644
--- a/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/ScheduledScanTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/ScheduledScanTest.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\RemoteRules;
use Automattic\WooCommerce\Internal\AgentReadiness\ScheduledScan;
use WC_Helper_Product;
use WC_Unit_Test_Case;
@@ -26,6 +27,7 @@ class ScheduledScanTest extends WC_Unit_Test_Case {
public function setUp(): void {
parent::setUp();
update_option( 'woocommerce_feature_agent_readiness_enabled', 'yes' );
+ add_filter( 'woocommerce_agent_readiness_rules_url', '__return_empty_string' );
delete_option( AgentReadiness::REPORT_OPTION );
as_unschedule_all_actions( ScheduledScan::RUN_NOW_HOOK );
as_unschedule_all_actions( ScheduledScan::DAILY_HOOK );
@@ -39,6 +41,7 @@ class ScheduledScanTest extends WC_Unit_Test_Case {
as_unschedule_all_actions( ScheduledScan::RUN_NOW_HOOK );
as_unschedule_all_actions( ScheduledScan::DAILY_HOOK );
delete_option( 'woocommerce_feature_agent_readiness_enabled' );
+ remove_filter( 'woocommerce_agent_readiness_rules_url', '__return_empty_string' );
parent::tearDown();
}
@@ -151,6 +154,27 @@ class ScheduledScanTest extends WC_Unit_Test_Case {
as_unschedule_all_actions( 'woocommerce_agent_readiness_test_other' );
}
+ /**
+ * @testdox A late scan on a queue that recently failed or started another action is not stalled.
+ * @testWith ["failed"]
+ * ["running"]
+ *
+ * @param string $state What happened to the other action.
+ */
+ public function test_late_scan_on_a_queue_running_other_actions_is_not_stalled( string $state ): 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' );
+ if ( 'failed' === $state ) {
+ \ActionScheduler::store()->mark_failure( $other );
+ } else {
+ \ActionScheduler::store()->log_execution( $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.
*/
@@ -176,6 +200,64 @@ class ScheduledScanTest extends WC_Unit_Test_Case {
$this->assertCount( 1, $this->pending( ScheduledScan::DAILY_HOOK ) );
}
+ /**
+ * @testdox A run fetches the rules file before scanning, so the new rules shape the report.
+ */
+ public function test_run_fetches_rules_before_scanning(): void {
+ add_filter(
+ 'woocommerce_agent_readiness_rules_url',
+ static function () {
+ return 'https://rules.example/agent-readiness.json';
+ }
+ );
+ $answer = static function () {
+ return array(
+ 'headers' => array(),
+ 'body' => '{"version":"2026-11-01","checks":{"missing_brand":{"channels":{"google":{"field":"brand","effect":"not_listable"}}}}}',
+ 'response' => array(
+ 'code' => 200,
+ 'message' => '',
+ ),
+ 'cookies' => array(),
+ );
+ };
+ add_filter( 'pre_http_request', $answer );
+ WC_Helper_Product::create_simple_product();
+
+ $this->sut->run();
+ remove_filter( 'pre_http_request', $answer );
+ delete_option( RemoteRules::OPTION );
+
+ $report = wc_get_container()->get( AgentReadiness::class )->get_cached_report();
+ $this->assertSame( 1, $report['channels']['google']['not_listable'] );
+ }
+
+ /**
+ * @testdox A failed rules fetch still stores a report from the bundled checks and leaves one daily run scheduled.
+ */
+ public function test_run_with_failed_fetch_uses_bundled_checks(): void {
+ add_filter(
+ 'woocommerce_agent_readiness_rules_url',
+ static function () {
+ return 'https://rules.example/agent-readiness.json';
+ }
+ );
+ $fail = static function () {
+ return new \WP_Error( 'http_request_failed', 'Operation timed out' );
+ };
+ add_filter( 'pre_http_request', $fail );
+ WC_Helper_Product::create_simple_product();
+
+ $this->sut->run();
+ remove_filter( 'pre_http_request', $fail );
+
+ $report = wc_get_container()->get( AgentReadiness::class )->get_cached_report();
+ $this->assertIsArray( $report );
+ $this->assertSame( 1, $report['products'] );
+ $this->assertContains( 'missing_images', array_column( $report['checks'], 'id' ), 'The bundled checks ran' );
+ $this->assertCount( 1, $this->pending( ScheduledScan::DAILY_HOOK ) );
+ }
+
/**
* @testdox A scan that throws still leaves the next daily run scheduled.
*/
@@ -209,11 +291,11 @@ class ScheduledScanTest extends WC_Unit_Test_Case {
}
/**
- * Date every finished action an hour back, so only what the test does next
- * counts as recent queue activity.
+ * Date every finished, failed or running 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 ) ) );
+ $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->prefix}actionscheduler_actions SET last_attempt_gmt = %s WHERE status IN ( 'complete', 'failed', 'in-progress' )", 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 009bfe90e78..3441897fbb9 100644
--- a/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/StatusTabTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/StatusTabTest.php
@@ -104,6 +104,6 @@ class StatusTabTest extends WC_Unit_Test_Case {
*/
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 ) ) );
+ $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->prefix}actionscheduler_actions SET last_attempt_gmt = %s WHERE status IN ( 'complete', 'failed', 'in-progress' )", gmdate( 'Y-m-d H:i:s', time() - HOUR_IN_SECONDS ) ) );
}
}