Commit de475728d4f for woocommerce
commit de475728d4f0ea53d592010334c5859f3576fffe
Author: AnnaMag <AnnaMag@users.noreply.github.com>
Date: Thu Nov 12 15:40:07 2026 +0100
Agent readiness: payment method and image alt text checks (#70435)
* Agent readiness: payment method and image alt text checks
Part of #70435.
* 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: drop without_empty(), which nothing calls any more
The rules file keeps the bundled channels for a check it lists without
any, and the filter now keeps a check that never had a channel, so
neither path calls it.
* Agent readiness: expect the image alt text check in the rules file tests
It has no channels, so it runs whatever the rules file lists.
* Agent readiness: name the image alt text check in the rules file instead of always running it
The check has no channels, so it was added back after the stored rules file
was applied and no file could turn it off. The file is the list of checks that
run again: rules.json names it with an empty channel map, and removing it from
the published file turns it off without a release.
diff --git a/plugins/woocommerce/changelog/add-agent-readiness-payment-alt-text b/plugins/woocommerce/changelog/add-agent-readiness-payment-alt-text
new file mode 100644
index 00000000000..c4bbac429d7
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-agent-readiness-payment-alt-text
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+Comment: Agent readiness: payment method and image alt text checks, behind an experimental flag; nothing ships to merchants.
+
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/AgentReadiness.php b/plugins/woocommerce/src/Internal/AgentReadiness/AgentReadiness.php
index 633a81b8f38..b83db495150 100644
--- a/plugins/woocommerce/src/Internal/AgentReadiness/AgentReadiness.php
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/AgentReadiness.php
@@ -102,6 +102,7 @@ class AgentReadiness {
$simple['variation_attributes_not_set'],
$simple['missing_variation_images'],
$simple['title_too_long'],
+ $simple['image_alt_text'],
) as $rule
) {
$rules[ $rule->id() ] = $rule;
@@ -113,7 +114,8 @@ class AgentReadiness {
* 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
+ * runs with its bundled channels. A check no channel asks for is named
+ * with an empty channel map. 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.
*
@@ -150,7 +152,15 @@ class AgentReadiness {
*/
$filtered = apply_filters( 'woocommerce_agent_readiness_rules', $defaults );
- return is_array( $filtered ) ? $this->without_empty( $this->sanitize_channel_rules( $filtered, $bundled ) ) : $defaults;
+ if ( ! is_array( $filtered ) ) {
+ return $defaults;
+ }
+ // A check left with no valid channel doesn't run, as if the filter had removed it, unless it never had one.
+ return array_filter(
+ $this->sanitize_channel_rules( $filtered, $bundled ),
+ static fn( array $channels, $id ): bool => ! empty( $channels ) || empty( $bundled[ $id ] ),
+ ARRAY_FILTER_USE_BOTH
+ );
}
/**
@@ -183,21 +193,6 @@ 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.
*
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/Checks/SimpleCheck.php b/plugins/woocommerce/src/Internal/AgentReadiness/Checks/SimpleCheck.php
index 8539036c30a..94705af9b8c 100644
--- a/plugins/woocommerce/src/Internal/AgentReadiness/Checks/SimpleCheck.php
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/Checks/SimpleCheck.php
@@ -148,6 +148,19 @@ final class SimpleCheck extends AbstractReadinessCheck {
self::on_every_channel( 'title', self::EFFECT_GAPS ),
static fn( \WC_Product $product ): bool => mb_strlen( $product->get_name() ) > self::TITLE_MAX_LENGTH
),
+ new self(
+ 'image_alt_text',
+ array(
+ 'label' => __( 'Products whose main image has no alt text', 'woocommerce' ),
+ 'why' => __( 'AI agents and screen readers read alt text to tell what an image shows. No channel\'s feed asks for it, so it doesn\'t change the channel counts.', 'woocommerce' ),
+ 'fix' => __( 'Describe the main image in its Alternative text field in the media library.', 'woocommerce' ),
+ ),
+ array(),
+ static function ( \WC_Product $product ): bool {
+ $image_id = ProductFields::image_id( $product );
+ return $image_id > 0 && '' === trim( (string) get_post_meta( $image_id, '_wp_attachment_image_alt', true ) );
+ }
+ ),
);
$by_id = array();
foreach ( $checks as $check ) {
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/ProductScan.php b/plugins/woocommerce/src/Internal/AgentReadiness/ProductScan.php
index ac22fc9f6e4..f1ef4960b20 100644
--- a/plugins/woocommerce/src/Internal/AgentReadiness/ProductScan.php
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/ProductScan.php
@@ -450,7 +450,8 @@ class ProductScan {
continue;
}
$channels = $rule->channels_for( $product, $channel_rules[ $id ] ?? array() );
- if ( ! empty( $channels ) ) {
+ // A check with no channel (about the product page, not a feed) still counts the product.
+ if ( ! empty( $channels ) || empty( $channel_rules[ $id ] ?? array() ) ) {
$failed[ (string) $id ] = $channels;
}
}
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/README.md b/plugins/woocommerce/src/Internal/AgentReadiness/README.md
index 9d638d99378..58ffe2d6e4d 100644
--- a/plugins/woocommerce/src/Internal/AgentReadiness/README.md
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/README.md
@@ -37,6 +37,9 @@ A channel whose spec does not ask for the field is left out of the check's map.
| `variation_attributes_not_set` | variant_dict, gaps | color, size, gaps | color, sizes, gaps |
| `missing_variation_images` | image_url, gaps | image_link, gaps | imageLink, gaps |
| `title_too_long` | title, gaps | title, gaps | title, gaps |
+| `image_alt_text` | | | |
+
+`image_alt_text` (the main image has no alt text) is about the product page, not a feed: no channel's spec asks for it. A check with no channel map lists its products but doesn't change the channel totals. Like every check, it runs once a rules file is stored only when the file names it, here with an empty channel map (`"image_alt_text": { "channels": {} }`), so removing it from the file turns it off. The rules filter can still remove it; a check whose channels the filter empties doesn't run.
A variable product with some priced variations fails `missing_regular_price` but counts as `gaps`, not `not_listable`, in the channel totals, since its priced variations still list.
@@ -49,7 +52,7 @@ Only published simple and variable products are checked, the types agents suppor
- Products hidden from the shop catalog (catalog visibility "Search results only" or "Hidden"). Agents see the products shoppers see in the shop, so hiding one is the merchant's choice, not a problem.
- Products of any other type: grouped, external, subscription, booking, bundle and so on. A product keeps its stored type when its plugin is inactive, so it stays in this group.
-Store-wide conditions (`StoreConditions`) are reported first because they affect every product: coming soon mode and "Discourage search engines" hide the products, UCP off and no HTTPS hide the catalog from UCP agents, and guest checkout off stops agents placing orders. Missing policy pages and order emails off weaken agent orders. Each condition's `blocks` says which, so a reader can tell a hidden store from one agents can't buy from.
+Store-wide conditions (`StoreConditions`) are reported first because they affect every product: coming soon mode and "Discourage search engines" hide the products, UCP off and no HTTPS hide the catalog from UCP agents, and guest checkout off or no payment method turned on stops agents placing orders. Missing policy pages and order emails off weaken agent orders. Each condition's `blocks` says which, so a reader can tell a hidden store from one agents can't buy from.
## Store output checks
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/RemoteRules.php b/plugins/woocommerce/src/Internal/AgentReadiness/RemoteRules.php
index 67891915985..2accf8f9ee4 100644
--- a/plugins/woocommerce/src/Internal/AgentReadiness/RemoteRules.php
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/RemoteRules.php
@@ -12,7 +12,9 @@ defined( 'ABSPATH' ) || exit;
* 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.
+ * bundled channels instead. A check no channel asks for, such as image alt
+ * text, is named with an empty channel map ("channels": {}). A file is only
+ * stored when at least one check in it has a valid channel.
*
* 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
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/StatusTab.php b/plugins/woocommerce/src/Internal/AgentReadiness/StatusTab.php
index f8d67d964a0..6bbbc21a3a6 100644
--- a/plugins/woocommerce/src/Internal/AgentReadiness/StatusTab.php
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/StatusTab.php
@@ -299,7 +299,7 @@ class StatusTab implements RegisterHooksInterface {
<p><?php echo esc_html( (string) $check['why'] ); ?></p>
<p><a href="<?php echo esc_url( (string) $check['fix_url'] ); ?>"><?php echo esc_html( (string) $check['fix'] ); ?></a></p>
</td>
- <td><?php echo wp_kses_post( implode( '<br />', $this->channel_tags( (array) $check['channels'], $channels ) ) ); ?></td>
+ <td><?php echo empty( $check['channels'] ) ? esc_html__( 'Product page only', 'woocommerce' ) : wp_kses_post( implode( '<br />', $this->channel_tags( (array) $check['channels'], $channels ) ) ); ?></td>
<td><?php echo esc_html( (string) (int) $check['count'] ); ?></td>
<td><?php echo wp_kses_post( implode( '<br />', $this->product_links( (array) $check['product_ids'] ) ) ); ?></td>
</tr>
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/StoreConditions.php b/plugins/woocommerce/src/Internal/AgentReadiness/StoreConditions.php
index 411e46b1401..3227b57e743 100644
--- a/plugins/woocommerce/src/Internal/AgentReadiness/StoreConditions.php
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/StoreConditions.php
@@ -139,6 +139,17 @@ class StoreConditions {
__( 'No shipping policy page', 'woocommerce' ),
__( 'Shoppers ask agents about delivery times and costs.', 'woocommerce' )
),
+ array(
+ 'id' => 'payment_method',
+ 'ok' => $this->payment_method_enabled(),
+ 'blocks' => 'checkout',
+ 'level' => self::LEVEL_RECOMMENDED,
+ 'name' => __( 'A payment method is turned on', 'woocommerce' ),
+ 'label' => __( 'No payment method is turned on', 'woocommerce' ),
+ 'why' => __( 'Shoppers sent by an agent cannot pay for their order.', 'woocommerce' ),
+ 'fix' => __( 'Turn on a payment method under WooCommerce > Settings > Payments.', 'woocommerce' ),
+ 'fix_url' => $settings_url( 'checkout' ),
+ ),
array(
'id' => 'guest_checkout',
'ok' => ! $this->registration_required(),
@@ -213,6 +224,19 @@ class StoreConditions {
return (bool) apply_filters( 'woocommerce_checkout_registration_required', 'yes' !== get_option( 'woocommerce_enable_guest_checkout' ) );
}
+ /**
+ * Whether any payment gateway is turned on. Whether it can take a given order
+ * depends on the cart, so this reads the setting, not is_available().
+ */
+ private function payment_method_enabled(): bool {
+ foreach ( WC()->payment_gateways()->payment_gateways() as $gateway ) {
+ if ( $gateway instanceof \WC_Payment_Gateway && 'yes' === $gateway->enabled ) {
+ return true;
+ }
+ }
+ return false;
+ }
+
/**
* Whether a shopper gets an order email after checkout. Virtual orders skip
* processing and are confirmed by the Completed order email instead.
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/rules.json b/plugins/woocommerce/src/Internal/AgentReadiness/rules.json
index 20415bf9101..3b255c0c6e2 100644
--- a/plugins/woocommerce/src/Internal/AgentReadiness/rules.json
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/rules.json
@@ -84,6 +84,9 @@
"google": { "field": "title", "effect": "gaps" },
"copilot": { "field": "title", "effect": "gaps" }
}
+ },
+ "image_alt_text": {
+ "channels": {}
}
}
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/ReadinessChecksTest.php b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/ReadinessChecksTest.php
index b9bb683a0ce..20ddd7fe1d7 100644
--- a/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/ReadinessChecksTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/ReadinessChecksTest.php
@@ -3,6 +3,7 @@ declare( strict_types = 1 );
namespace Automattic\WooCommerce\Tests\Internal\AgentReadiness;
+use Automattic\WooCommerce\Internal\AgentReadiness\AgentReadiness;
use Automattic\WooCommerce\Internal\AgentReadiness\Checks\DuplicateGtinCheck;
use Automattic\WooCommerce\Internal\AgentReadiness\Checks\ImageTooSmallCheck;
use Automattic\WooCommerce\Internal\AgentReadiness\Checks\MissingBrandCheck;
@@ -61,7 +62,8 @@ class ReadinessChecksTest extends WC_Unit_Test_Case {
$table = SimpleCheck::table();
foreach ( $table as $id => $check ) {
$this->assertSame( $id, $check->id() );
- $this->assertSame( array_keys( FeedChannels::all() ), array_keys( $check->channels() ), "$id names a field on every channel" );
+ $expected = 'image_alt_text' === $id ? array() : array_keys( FeedChannels::all() );
+ $this->assertSame( $expected, array_keys( $check->channels() ), "$id names a field on every channel, or none for a page check" );
}
$simple = WC_Helper_Product::create_simple_product();
@@ -81,6 +83,87 @@ class ReadinessChecksTest extends WC_Unit_Test_Case {
$this->assertTrue( $table['variation_attributes_not_set']->fails( wc_get_product( $variable->get_id() ), $this->scan ), 'A variation set to "any"' );
}
+ /**
+ * @testdox Image alt text is read from the main image only: not the gallery, and a variable product by its own image.
+ */
+ public function test_image_alt_text(): void {
+ $sut = SimpleCheck::table()['image_alt_text'];
+ $image = self::factory()->attachment->create( array( 'post_mime_type' => 'image/jpeg' ) );
+ $gallery = self::factory()->attachment->create( array( 'post_mime_type' => 'image/jpeg' ) );
+ $product = WC_Helper_Product::create_simple_product();
+ $this->assertFalse( $sut->fails( $product, $this->scan ), 'No image is missing_images, not this check' );
+
+ $product->set_image_id( $image );
+ $product->set_gallery_image_ids( array( $gallery ) );
+ $product->save();
+ $this->assertTrue( $sut->fails( $product, $this->scan ) );
+ update_post_meta( $image, '_wp_attachment_image_alt', ' ' );
+ $this->assertTrue( $sut->fails( $product, $this->scan ), 'Blank alt text is no alt text' );
+ update_post_meta( $image, '_wp_attachment_image_alt', 'Blue wool beanie' );
+ $this->assertFalse( $sut->fails( $product, $this->scan ), 'A gallery image without alt text does not count' );
+
+ $variable = WC_Helper_Product::create_variation_product();
+ $variation = wc_get_product( $variable->get_children()[0] );
+ $variation->set_image_id( $gallery );
+ $variation->save();
+ $variable->set_image_id( $image );
+ $variable->save();
+ $this->assertFalse( $sut->fails( wc_get_product( $variable->get_id() ), $this->scan ), 'A variation image without alt text does not count' );
+ }
+
+ /**
+ * @testdox A check with no channel lists the product with no channel effect, and the rules filter can still remove it.
+ */
+ public function test_check_with_no_channel(): void {
+ StoreOutputChecksTest::block_requests();
+ $product = WC_Helper_Product::create_simple_product();
+ wp_set_object_terms( $product->get_id(), 'Acme', 'product_brand' );
+ $image = self::factory()->attachment->create( array( 'post_mime_type' => 'image/jpeg' ) );
+ $product->set_image_id( $image );
+ $product->save();
+ $readiness = wc_get_container()->get( AgentReadiness::class );
+
+ $result = $readiness->check_product( wc_get_product( $product->get_id() ) );
+ $this->assertArrayHasKey( 'image_alt_text', $result['checks'] );
+ $this->assertSame( array(), $result['checks']['image_alt_text'] );
+
+ $checks = array_column( $readiness->get_report()['checks'], null, 'id' );
+ $this->assertSame( 1, $checks['image_alt_text']['count'] );
+
+ add_filter(
+ 'woocommerce_agent_readiness_rules',
+ static function ( $rules ) {
+ unset( $rules['image_alt_text'] );
+ return $rules;
+ }
+ );
+ $this->assertArrayNotHasKey( 'image_alt_text', wc_get_container()->get( AgentReadiness::class )->get_channel_rules() );
+ }
+
+ /**
+ * @testdox The payment method check passes while any gateway is turned on.
+ */
+ public function test_payment_method_condition(): void {
+ $gateways = WC()->payment_gateways()->payment_gateways();
+ $enabled = array_map( static fn( $gateway ) => $gateway->enabled, $gateways );
+ $sut = wc_get_container()->get( StoreConditions::class );
+ $ok = static fn() => array_column( $sut->all(), 'ok', 'id' )['payment_method'];
+ $this->assertNotEmpty( $gateways, 'Core registers its gateways' );
+ try {
+ foreach ( $gateways as $gateway ) {
+ $gateway->enabled = 'no';
+ }
+ $this->assertFalse( $ok() );
+
+ reset( $gateways )->enabled = 'yes';
+ $this->assertTrue( $ok() );
+ } finally {
+ foreach ( $gateways as $id => $gateway ) {
+ $gateway->enabled = $enabled[ $id ];
+ }
+ }
+ }
+
/**
* @testdox A brand set only in Google for WooCommerce still fails, but no longer counts against Google.
*/
@@ -409,6 +492,7 @@ class ReadinessChecksTest extends WC_Unit_Test_Case {
'terms_page' => 'checkout',
'refund_returns_page' => 'checkout',
'shipping_policy_page' => 'checkout',
+ 'payment_method' => 'checkout',
'guest_checkout' => 'checkout',
'order_emails' => 'checkout',
),
diff --git a/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/RemoteRulesTest.php b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/RemoteRulesTest.php
index d1ac48852a0..de890effb9d 100644
--- a/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/RemoteRulesTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/RemoteRulesTest.php
@@ -116,9 +116,9 @@ class RemoteRulesTest extends WC_Unit_Test_Case {
private function agent_readiness(): AgentReadiness {
$store_conditions = new StoreConditions();
$store_conditions->init( new ComingSoonHelper() );
- $agent_readiness = new AgentReadiness();
- $output_checks = new StoreOutputChecks();
+ $output_checks = new StoreOutputChecks();
$output_checks->init( new ComingSoonHelper() );
+ $agent_readiness = new AgentReadiness();
$agent_readiness->init( $store_conditions, $output_checks, $this->sut );
return $agent_readiness;
}
@@ -134,6 +134,32 @@ class RemoteRulesTest extends WC_Unit_Test_Case {
$this->assertArrayHasKey( 'missing_images', $this->agent_readiness()->get_channel_rules(), 'With no file, the bundled checks run' );
}
+ /**
+ * @testdox A check no channel asks for, such as image alt text, runs when the file names it with an empty channel map, and not when the file leaves it out.
+ */
+ public function test_check_with_no_channels_follows_the_file(): void {
+ $brand = array(
+ 'google' => array(
+ 'field' => 'brand',
+ 'effect' => 'not_listable',
+ ),
+ );
+ $this->serve( array( 'missing_brand' => $brand ) );
+ $this->assertTrue( $this->sut->refresh() );
+ $this->assertArrayNotHasKey( 'image_alt_text', $this->agent_readiness()->get_channel_rules(), 'Left out of the file, it does not run' );
+
+ $this->serve(
+ array(
+ 'missing_brand' => $brand,
+ 'image_alt_text' => new \stdClass(),
+ )
+ );
+ $this->assertTrue( $this->sut->refresh() );
+ $rules = $this->agent_readiness()->get_channel_rules();
+ $this->assertSame( array(), $rules['image_alt_text'], 'Named with "channels": {}, it runs' );
+ $this->assertArrayNotHasKey( 'missing_images', $rules, 'A channel check the file leaves out still does not run' );
+ }
+
/**
* @testdox A fetched rules file replaces a check's bundled channel map in the report.
*/
@@ -516,7 +542,7 @@ class RemoteRulesTest extends WC_Unit_Test_Case {
);
$this->assertTrue( $this->sut->refresh() );
$before = $this->agent_readiness()->get_channel_rules();
- $this->assertSame( array( 'missing_brand' ), array_keys( $before ) );
+ $this->assertSame( array( 'missing_brand' ), array_keys( $before ), 'Only the file\'s check runs' );
foreach (
array(
@@ -623,16 +649,23 @@ class RemoteRulesTest extends WC_Unit_Test_Case {
$this->assertIsArray( $file );
$this->assertIsArray( $file['checks'] );
- $known = array_keys( ( new AgentReadiness() )->get_default_rules() );
+ $rules = ( new AgentReadiness() )->get_default_rules();
+ $known = array_keys( $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' );
+ $expected = $known;
+ $listed = array_keys( $checks );
+ sort( $expected );
+ sort( $listed );
+ $this->assertSame( $expected, $listed, '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() ) );
+ $running = array_keys( $this->agent_readiness()->get_channel_rules() );
+ sort( $running );
+ $this->assertSame( $expected, $running, 'Every check runs, including one no channel asks for' );
}
}