Commit 326630eae82 for woocommerce
commit 326630eae82a1bdfea56c214036307d6ad9f54f9
Author: AnnaMag <AnnaMag@users.noreply.github.com>
Date: Thu Nov 5 14:55:09 2026 +0100
Agent readiness: product and store checks behind a feature flag (#70328)
* Agent readiness: product and store checks behind a feature flag
Part of #70328.
* Agent readiness: ask each variation for its own GTIN
missing_gtin passed a variable product as soon as one variation had a
GTIN, although channels list each variation as its own item and the UCP
catalog sends each variation's own GTIN. It now fails when any published
variation that needs one has no GTIN of its own; the variable parent needs
none. The README no longer says UCP sends no GTINs.
diff --git a/plugins/woocommerce/changelog/add-agent-readiness-status b/plugins/woocommerce/changelog/add-agent-readiness-status
new file mode 100644
index 00000000000..e2cfd52055a
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-agent-readiness-status
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add an experimental Agent readiness tab under WooCommerce > Status that checks published simple and variable products against AI shopping channel specs, and counts products hidden from the catalog or of unsupported types.
diff --git a/plugins/woocommerce/includes/class-woocommerce.php b/plugins/woocommerce/includes/class-woocommerce.php
index 75a1fe4f562..f4e7b25c0ed 100644
--- a/plugins/woocommerce/includes/class-woocommerce.php
+++ b/plugins/woocommerce/includes/class-woocommerce.php
@@ -453,6 +453,7 @@ final class WooCommerce {
$container->get( Automattic\WooCommerce\Internal\ScheduledSalePriceReconciler::class )->register();
$container->get( Automattic\WooCommerce\Internal\OrderWithdrawal\OrderWithdrawalController::class )->register();
$container->get( Automattic\WooCommerce\Internal\Ucp\UcpDiscovery::class )->register();
+ $container->get( Automattic\WooCommerce\Internal\AgentReadiness\StatusTab::class )->register();
$container->get( Automattic\WooCommerce\Internal\Admin\OrderTaxLookupMigrator::class )->register();
$container->get( Automattic\WooCommerce\Internal\ProductDownloads\DownloadableFileAttachments::class )->register();
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/AgentReadiness.php b/plugins/woocommerce/src/Internal/AgentReadiness/AgentReadiness.php
new file mode 100644
index 00000000000..e0f376ae09c
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/AgentReadiness.php
@@ -0,0 +1,283 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\AgentReadiness;
+
+use Automattic\WooCommerce\Internal\AgentReadiness\Checks\DuplicateGtinCheck;
+use Automattic\WooCommerce\Internal\AgentReadiness\Checks\ImageTooSmallCheck;
+use Automattic\WooCommerce\Internal\AgentReadiness\Checks\MissingBrandCheck;
+use Automattic\WooCommerce\Internal\AgentReadiness\Checks\MissingGtinCheck;
+use Automattic\WooCommerce\Internal\AgentReadiness\Checks\MissingRegularPriceCheck;
+use Automattic\WooCommerce\Internal\AgentReadiness\Checks\SimpleCheck;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Runs the readiness checks and returns the shared report format described in
+ * this directory's README.
+ *
+ * @since 11.4.0
+ */
+class AgentReadiness {
+
+ /**
+ * Product ids kept per check in the report.
+ */
+ public const PRODUCT_IDS_LIMIT = 10;
+
+ /**
+ * Transient holding the last report.
+ */
+ public const REPORT_TRANSIENT = 'woocommerce_agent_readiness_report';
+
+ /**
+ * Store-wide conditions.
+ *
+ * @var StoreConditions
+ */
+ private StoreConditions $store_conditions;
+
+ /**
+ * Scan reused for single-product checks, so its shared GTIN lookup runs once per request.
+ *
+ * @var ProductScan|null
+ */
+ private ?ProductScan $product_scan = null;
+
+ /**
+ * Rules that run and their channel maps, built once per instance.
+ *
+ * @var array{rules:array<string, ReadinessRule>,channel_rules:array<string, array<string, array{field:string,effect:string}>>}|null
+ */
+ private ?array $active_rules = null;
+
+ /**
+ * Initialize dependencies.
+ *
+ * @internal
+ *
+ * @param StoreConditions $store_conditions Store-wide conditions.
+ */
+ final public function init( StoreConditions $store_conditions ): void {
+ $this->store_conditions = $store_conditions;
+ }
+
+ /**
+ * The bundled checks, keyed by id.
+ *
+ * @return array<string, ReadinessRule>
+ */
+ public function get_default_rules(): array {
+ $simple = SimpleCheck::table();
+ $rules = array();
+ foreach (
+ array(
+ new MissingRegularPriceCheck(),
+ $simple['missing_images'],
+ $simple['missing_product_text'],
+ new MissingBrandCheck(),
+ $simple['invalid_gtin'],
+ new MissingGtinCheck(),
+ new DuplicateGtinCheck(),
+ new ImageTooSmallCheck(),
+ $simple['uncategorised'],
+ $simple['variation_attributes_not_set'],
+ $simple['missing_variation_images'],
+ $simple['title_too_long'],
+ ) as $rule
+ ) {
+ $rules[ $rule->id() ] = $rule;
+ }
+ return $rules;
+ }
+
+ /**
+ * 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.
+ *
+ * @return array<string, array<string, array{field:string,effect:string}>>
+ */
+ public function get_channel_rules(): array {
+ $defaults = array();
+ foreach ( $this->get_default_rules() as $id => $rule ) {
+ $defaults[ $id ] = $rule->channels();
+ }
+
+ /**
+ * Filters the per-channel field and effect of each agent readiness check.
+ *
+ * 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.
+ *
+ * @since 11.4.0
+ *
+ * @param array $rules Field and effect by channel, keyed by check id.
+ */
+ $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;
+ }
+
+ /**
+ * Keep only known checks, known channels and valid effects from a filtered rules array.
+ *
+ * @param array $filtered Filtered rules.
+ * @param array<string, array<string, array{field:string,effect:string}>> $defaults Bundled rules.
+ * @return array<string, array<string, array{field:string,effect:string}>>
+ */
+ private function sanitize_channel_rules( array $filtered, array $defaults ): array {
+ $channels = FeedChannels::all();
+ $effects = array( ReadinessRule::EFFECT_NOT_LISTABLE, ReadinessRule::EFFECT_GAPS );
+ $rules = array();
+ foreach ( $filtered as $id => $rule_channels ) {
+ if ( ! isset( $defaults[ $id ] ) || ! is_array( $rule_channels ) ) {
+ continue;
+ }
+ $rules[ $id ] = array();
+ foreach ( $rule_channels as $channel => $spec ) {
+ if ( ! isset( $channels[ $channel ] ) || ! is_array( $spec ) || ! in_array( $spec['effect'] ?? null, $effects, true ) ) {
+ continue;
+ }
+ $rules[ $id ][ $channel ] = array(
+ 'field' => (string) ( $spec['field'] ?? '' ),
+ 'effect' => $spec['effect'],
+ );
+ }
+ }
+ return $rules;
+ }
+
+ /**
+ * 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.
+ $this->active_rules = null;
+ $this->product_scan = null;
+ $active = $this->get_active_rules();
+ $rules = $active['rules'];
+ $channel_rules = $active['channel_rules'];
+
+ $scan = new ProductScan();
+ $scan->evaluate( $rules, $channel_rules, self::PRODUCT_IDS_LIMIT );
+
+ $checks = array();
+ foreach ( $rules as $id => $rule ) {
+ $result = $scan->result( $id );
+ $checks[] = array(
+ 'id' => $id,
+ 'label' => $rule->label(),
+ 'why' => $rule->why(),
+ 'fix' => $rule->fix(),
+ 'channels' => $channel_rules[ $id ],
+ 'count' => $result['count'],
+ 'product_ids' => $result['product_ids'],
+ 'fix_url' => admin_url( 'edit.php?post_type=product' ),
+ );
+ }
+
+ return array(
+ 'generated_at' => gmdate( 'c' ),
+ 'products' => $scan->products(),
+ 'hidden_from_catalog' => $scan->hidden_from_catalog(),
+ 'unsupported_types' => $scan->unsupported_types(),
+ 'store_checks' => $this->store_conditions->all(),
+ 'channels' => $scan->channel_totals(),
+ 'checks' => $checks,
+ 'truncated' => $scan->truncated(),
+ );
+ }
+
+ /**
+ * The last stored report, or null when there is none.
+ *
+ * @return array<string, mixed>|null
+ */
+ public function get_cached_report(): ?array {
+ $report = get_transient( self::REPORT_TRANSIENT );
+ return is_array( $report ) ? $report : null;
+ }
+
+ /**
+ * Run the report and store it for an hour.
+ *
+ * @return array<string, mixed>
+ */
+ public function refresh_report(): array {
+ $report = $this->get_report();
+ set_transient( self::REPORT_TRANSIENT, $report, HOUR_IN_SECONDS );
+ return $report;
+ }
+
+ /**
+ * Ids of the checks one product fails. A variation is judged by its parent,
+ * as the scan counts it; other products the scan leaves out (other types,
+ * hidden from the shop catalog, unpublished) have no issues.
+ *
+ * @param \WC_Product $product Product or variation.
+ * @return array<int, string>
+ */
+ public function get_failing_check_ids( \WC_Product $product ): array {
+ $result = $this->check_product( $product );
+ return null === $result ? array() : array_map( 'strval', array_keys( $result['checks'] ) );
+ }
+
+ /**
+ * One product as the scan judges it: each failing check with its field and
+ * effect per channel for this product, and the product's status on each
+ * channel. Null for products the scan leaves out (grouped, external,
+ * unpublished). A variation is judged by its parent.
+ *
+ * For extensions that rank or fix products one at a time; the stored
+ * report keeps only counts and a few example ids per check. Shared GTINs
+ * are read once per report, so call get_report() again after changing GTINs.
+ *
+ * @param \WC_Product $product Product or variation.
+ * @return array{product_id:int,checks:array<string, array<string, array{field:string,effect:string}>>,statuses:array<string, string>}|null
+ */
+ public function check_product( \WC_Product $product ): ?array {
+ if ( $product instanceof \WC_Product_Variation ) {
+ $parent = wc_get_product( $product->get_parent_id() );
+ if ( ! $parent instanceof \WC_Product ) {
+ return null;
+ }
+ $product = $parent;
+ }
+ if ( ! ProductScan::covers( $product ) ) {
+ return null;
+ }
+ if ( null === $this->product_scan ) {
+ $this->product_scan = new ProductScan();
+ }
+ $active = $this->get_active_rules();
+ $checks = $this->product_scan->failures( $product, $active['rules'], $active['channel_rules'] );
+ return array(
+ 'product_id' => $product->get_id(),
+ 'checks' => $checks,
+ 'statuses' => ProductScan::statuses( array_values( $checks ) ),
+ );
+ }
+
+ /**
+ * The checks that run after the rules filter, with their channel maps.
+ * Built once, so the feed path does not rebuild them per product.
+ *
+ * @return array{rules:array<string, ReadinessRule>,channel_rules:array<string, array<string, array{field:string,effect:string}>>}
+ */
+ private function get_active_rules(): array {
+ if ( null === $this->active_rules ) {
+ $channel_rules = $this->get_channel_rules();
+ $this->active_rules = array(
+ 'rules' => array_intersect_key( $this->get_default_rules(), $channel_rules ),
+ 'channel_rules' => $channel_rules,
+ );
+ }
+ return $this->active_rules;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/Checks/AbstractReadinessCheck.php b/plugins/woocommerce/src/Internal/AgentReadiness/Checks/AbstractReadinessCheck.php
new file mode 100644
index 00000000000..670f937c40a
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/Checks/AbstractReadinessCheck.php
@@ -0,0 +1,45 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\AgentReadiness\Checks;
+
+use Automattic\WooCommerce\Internal\AgentReadiness\FeedChannels;
+use Automattic\WooCommerce\Internal\AgentReadiness\ReadinessRule;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Shared defaults for readiness checks.
+ *
+ * @since 11.4.0
+ */
+abstract class AbstractReadinessCheck implements ReadinessRule {
+
+ /**
+ * The same field and effect on every channel.
+ *
+ * @param string $field Feed field.
+ * @param string $effect EFFECT_NOT_LISTABLE or EFFECT_GAPS.
+ * @return array<string, array{field:string,effect:string}>
+ */
+ protected static function on_every_channel( string $field, string $effect ): array {
+ return array_fill_keys(
+ array_keys( FeedChannels::all() ),
+ array(
+ 'field' => $field,
+ 'effect' => $effect,
+ )
+ );
+ }
+
+ /**
+ * Field and effect by channel for one failing product: the given map.
+ *
+ * @param \WC_Product $product Product that fails the rule.
+ * @param array<string, array{field:string,effect:string}> $channels The rule's channel map.
+ * @return array<string, array{field:string,effect:string}>
+ */
+ public function channels_for( \WC_Product $product, array $channels ): array {
+ return $channels;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/Checks/DuplicateGtinCheck.php b/plugins/woocommerce/src/Internal/AgentReadiness/Checks/DuplicateGtinCheck.php
new file mode 100644
index 00000000000..1254635bae3
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/Checks/DuplicateGtinCheck.php
@@ -0,0 +1,79 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\AgentReadiness\Checks;
+
+use Automattic\WooCommerce\Internal\AgentReadiness\FeedChannels;
+use Automattic\WooCommerce\Internal\AgentReadiness\ProductFields;
+use Automattic\WooCommerce\Internal\AgentReadiness\ProductScan;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * A GTIN on the product or its variations is also on another published
+ * product. Only Google's spec addresses this.
+ *
+ * @see https://support.google.com/merchants/answer/6324461
+ *
+ * @since 11.4.0
+ */
+class DuplicateGtinCheck extends AbstractReadinessCheck {
+
+ /**
+ * Check id.
+ */
+ public function id(): string {
+ return 'duplicate_gtin';
+ }
+
+ /**
+ * Merchant-facing label.
+ */
+ public function label(): string {
+ return __( 'Products sharing a GTIN with another product', 'woocommerce' );
+ }
+
+ /**
+ * Why it matters.
+ */
+ public function why(): string {
+ return __( 'Google asks for one GTIN per product and variant; a shared one can merge different products into one item.', 'woocommerce' );
+ }
+
+ /**
+ * What to do about it.
+ */
+ public function fix(): string {
+ return __( 'Give each product its own GTIN.', 'woocommerce' );
+ }
+
+ /**
+ * Field and effect by channel.
+ */
+ public function channels(): array {
+ return array(
+ FeedChannels::GOOGLE => array(
+ 'field' => 'gtin',
+ 'effect' => self::EFFECT_GAPS,
+ ),
+ );
+ }
+
+ /**
+ * Whether the product has this problem.
+ *
+ * @param \WC_Product $product Published product.
+ * @param ProductScan $scan Catalog-wide lookups.
+ */
+ public function fails( \WC_Product $product, ProductScan $scan ): bool {
+ if ( ! ProductFields::needs_identifiers( $product ) ) {
+ return false;
+ }
+ foreach ( ProductFields::gtins( $product ) as $gtin ) {
+ if ( $scan->is_shared_gtin( $gtin ) ) {
+ return true;
+ }
+ }
+ return false;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/Checks/ImageTooSmallCheck.php b/plugins/woocommerce/src/Internal/AgentReadiness/Checks/ImageTooSmallCheck.php
new file mode 100644
index 00000000000..84005fdb204
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/Checks/ImageTooSmallCheck.php
@@ -0,0 +1,100 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\AgentReadiness\Checks;
+
+use Automattic\WooCommerce\Internal\AgentReadiness\FeedChannels;
+use Automattic\WooCommerce\Internal\AgentReadiness\ProductFields;
+use Automattic\WooCommerce\Internal\AgentReadiness\ProductScan;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * The main image is under MIN_PIXELS on either side. An image with no recorded
+ * size is not flagged. Google requires 500 x 500 from January 31, 2027; before
+ * that it is a gap.
+ *
+ * @see https://support.google.com/merchants/answer/6324350
+ *
+ * @since 11.4.0
+ */
+class ImageTooSmallCheck extends AbstractReadinessCheck {
+
+ public const MIN_PIXELS = 500;
+
+ public const GOOGLE_REQUIRED_FROM = '2027-01-31';
+
+ /**
+ * Unix time the Google effect is judged at, or null for now.
+ *
+ * @var int|null
+ */
+ private ?int $now;
+
+ /**
+ * Constructor.
+ *
+ * @param int|null $now Unix time to judge Google's deadline at; null for the current time.
+ */
+ public function __construct( ?int $now = null ) {
+ $this->now = $now;
+ }
+
+ /**
+ * Check id.
+ */
+ public function id(): string {
+ return 'image_too_small';
+ }
+
+ /**
+ * Merchant-facing label.
+ */
+ public function label(): string {
+ return __( 'Products whose main image is under 500 x 500 pixels', 'woocommerce' );
+ }
+
+ /**
+ * Why it matters.
+ */
+ public function why(): string {
+ return __( 'Google\'s spec requires a main image of at least 500 x 500 pixels from January 31, 2027; a smaller one won\'t appear on Google then.', 'woocommerce' );
+ }
+
+ /**
+ * What to do about it.
+ */
+ public function fix(): string {
+ return __( 'Upload a main image at least 500 x 500 pixels.', 'woocommerce' );
+ }
+
+ /**
+ * Field and effect by channel.
+ */
+ public function channels(): array {
+ return array(
+ FeedChannels::GOOGLE => array(
+ 'field' => 'image_link',
+ 'effect' => gmdate( 'Y-m-d', $this->now ?? time() ) < self::GOOGLE_REQUIRED_FROM ? self::EFFECT_GAPS : self::EFFECT_NOT_LISTABLE,
+ ),
+ );
+ }
+
+ /**
+ * Whether the product has this problem.
+ *
+ * @param \WC_Product $product Published product.
+ * @param ProductScan $scan Catalog-wide lookups.
+ */
+ public function fails( \WC_Product $product, ProductScan $scan ): bool {
+ $image_id = ProductFields::image_id( $product );
+ if ( 0 === $image_id ) {
+ return false;
+ }
+ $metadata = wp_get_attachment_metadata( $image_id );
+ if ( ! is_array( $metadata ) || empty( $metadata['width'] ) || empty( $metadata['height'] ) ) {
+ return false;
+ }
+ return (int) $metadata['width'] < self::MIN_PIXELS || (int) $metadata['height'] < self::MIN_PIXELS;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/Checks/MissingBrandCheck.php b/plugins/woocommerce/src/Internal/AgentReadiness/Checks/MissingBrandCheck.php
new file mode 100644
index 00000000000..56afc71717c
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/Checks/MissingBrandCheck.php
@@ -0,0 +1,92 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\AgentReadiness\Checks;
+
+use Automattic\WooCommerce\Internal\AgentReadiness\FeedChannels;
+use Automattic\WooCommerce\Internal\AgentReadiness\ProductFields;
+use Automattic\WooCommerce\Internal\AgentReadiness\ProductScan;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * No brand term and no brand attribute. Gift cards and virtual or
+ * downloadable products are not asked for one. A brand set only in Google for
+ * WooCommerce reaches Google, so Google is left out for those products.
+ *
+ * @since 11.4.0
+ */
+class MissingBrandCheck extends AbstractReadinessCheck {
+
+ /**
+ * Check id.
+ */
+ public function id(): string {
+ return 'missing_brand';
+ }
+
+ /**
+ * Merchant-facing label.
+ */
+ public function label(): string {
+ return __( 'Products with no brand', 'woocommerce' );
+ }
+
+ /**
+ * Why it matters.
+ */
+ public function why(): string {
+ return __( 'ChatGPT\'s spec requires a brand, so the product can\'t be listed there without one. Google and Copilot list it with a warning.', 'woocommerce' );
+ }
+
+ /**
+ * What to do about it.
+ */
+ public function fix(): string {
+ return __( 'Add a brand. Google allows an empty brand only when the product truly has none.', 'woocommerce' );
+ }
+
+ /**
+ * Field and effect by channel.
+ */
+ public function channels(): array {
+ return array(
+ FeedChannels::CHATGPT => array(
+ 'field' => 'brand',
+ 'effect' => self::EFFECT_NOT_LISTABLE,
+ ),
+ FeedChannels::GOOGLE => array(
+ 'field' => 'brand',
+ 'effect' => self::EFFECT_GAPS,
+ ),
+ FeedChannels::COPILOT => array(
+ 'field' => 'brand',
+ 'effect' => self::EFFECT_GAPS,
+ ),
+ );
+ }
+
+ /**
+ * Drop Google when Google for WooCommerce sends its own brand.
+ *
+ * @param \WC_Product $product Product that fails the rule.
+ * @param array<string, array{field:string,effect:string}> $channels The rule's channel map.
+ * @return array<string, array{field:string,effect:string}>
+ */
+ public function channels_for( \WC_Product $product, array $channels ): array {
+ if ( '' !== ProductFields::google_feed_brand( $product ) ) {
+ unset( $channels[ FeedChannels::GOOGLE ] );
+ }
+ return $channels;
+ }
+
+ /**
+ * Whether the product has this problem.
+ *
+ * @param \WC_Product $product Published product.
+ * @param ProductScan $scan Catalog-wide lookups.
+ */
+ public function fails( \WC_Product $product, ProductScan $scan ): bool {
+ return ProductFields::needs_identifiers( $product ) && '' === ProductFields::brand( $product );
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/Checks/MissingGtinCheck.php b/plugins/woocommerce/src/Internal/AgentReadiness/Checks/MissingGtinCheck.php
new file mode 100644
index 00000000000..822427060a5
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/Checks/MissingGtinCheck.php
@@ -0,0 +1,97 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\AgentReadiness\Checks;
+
+use Automattic\WooCommerce\Internal\AgentReadiness\FeedChannels;
+use Automattic\WooCommerce\Internal\AgentReadiness\ProductFields;
+use Automattic\WooCommerce\Internal\AgentReadiness\ProductScan;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * A product, or one of its published variations, without its own GTIN. Each
+ * variation is listed as its own item, so it needs its own GTIN: the parent's
+ * identifies a different item, and a variable parent needs none, even when
+ * none of its variations is published. Gift cards
+ * and virtual or downloadable products or variations are not asked for one.
+ * Google accepts a brand and MPN instead, so Google is left out when the
+ * product has both.
+ *
+ * @since 11.4.0
+ */
+class MissingGtinCheck extends AbstractReadinessCheck {
+
+ /**
+ * Check id.
+ */
+ public function id(): string {
+ return 'missing_gtin';
+ }
+
+ /**
+ * Merchant-facing label.
+ */
+ public function label(): string {
+ return __( 'Products missing a GTIN', 'woocommerce' );
+ }
+
+ /**
+ * Why it matters.
+ */
+ public function why(): string {
+ return __( 'Channels match products to searches and to other sellers by GTIN; without one the product is harder to find. Each variation lists as its own item, so it needs its own GTIN.', 'woocommerce' );
+ }
+
+ /**
+ * What to do about it.
+ */
+ public function fix(): string {
+ return __( 'Add the GTIN (UPC, EAN or ISBN) from the packaging or supplier to the product, or to each variation of a variable product; never make one up.', 'woocommerce' );
+ }
+
+ /**
+ * Field and effect by channel.
+ */
+ public function channels(): array {
+ return self::on_every_channel( 'gtin', self::EFFECT_GAPS );
+ }
+
+ /**
+ * Drop Google when the product has a brand and an MPN.
+ *
+ * @param \WC_Product $product Product that fails the rule.
+ * @param array<string, array{field:string,effect:string}> $channels The rule's channel map.
+ * @return array<string, array{field:string,effect:string}>
+ */
+ public function channels_for( \WC_Product $product, array $channels ): array {
+ $brand = ProductFields::brand( $product );
+ if ( '' === $brand ) {
+ $brand = ProductFields::google_feed_brand( $product );
+ }
+ if ( '' !== $brand && '' !== ProductFields::mpn( $product ) ) {
+ unset( $channels[ FeedChannels::GOOGLE ] );
+ }
+ return $channels;
+ }
+
+ /**
+ * Whether the product has this problem.
+ *
+ * @param \WC_Product $product Published product.
+ * @param ProductScan $scan Catalog-wide lookups.
+ */
+ public function fails( \WC_Product $product, ProductScan $scan ): bool {
+ if ( ! ProductFields::needs_identifiers( $product ) ) {
+ return false;
+ }
+ // A variable product with no published variation lists nothing, so it needs no GTIN.
+ $items = $product instanceof \WC_Product_Variable ? ProductFields::variations( $product ) : array( $product );
+ foreach ( $items as $item ) {
+ if ( ProductFields::needs_identifiers( $item ) && '' === ProductFields::normalise_gtin( (string) $item->get_global_unique_id() ) ) {
+ return true;
+ }
+ }
+ return false;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/Checks/MissingRegularPriceCheck.php b/plugins/woocommerce/src/Internal/AgentReadiness/Checks/MissingRegularPriceCheck.php
new file mode 100644
index 00000000000..b59261c999d
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/Checks/MissingRegularPriceCheck.php
@@ -0,0 +1,111 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\AgentReadiness\Checks;
+
+use Automattic\WooCommerce\Internal\AgentReadiness\ProductFields;
+use Automattic\WooCommerce\Internal\AgentReadiness\ProductScan;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * No positive regular or active price. A variable product fails when any
+ * published variation has neither; it can't be listed only when none has one,
+ * since the priced variations still list. Bundles, composites and grouped
+ * products are priced from their items and are not checked.
+ *
+ * @since 11.4.0
+ */
+class MissingRegularPriceCheck extends AbstractReadinessCheck {
+
+ /**
+ * Check id.
+ */
+ public function id(): string {
+ return 'missing_regular_price';
+ }
+
+ /**
+ * Merchant-facing label.
+ */
+ public function label(): string {
+ return __( 'Products with no price', 'woocommerce' );
+ }
+
+ /**
+ * Why it matters.
+ */
+ public function why(): string {
+ return __( 'Every channel\'s spec requires a price, and Google rejects a product without one. A variable product still lists its priced variations.', 'woocommerce' );
+ }
+
+ /**
+ * What to do about it.
+ */
+ public function fix(): string {
+ return __( 'Set a price on the product, or on each variation.', 'woocommerce' );
+ }
+
+ /**
+ * Field and effect by channel.
+ */
+ public function channels(): array {
+ return self::on_every_channel( 'price', self::EFFECT_NOT_LISTABLE );
+ }
+
+ /**
+ * Whether the product has this problem.
+ *
+ * @param \WC_Product $product Published product.
+ * @param ProductScan $scan Catalog-wide lookups.
+ */
+ public function fails( \WC_Product $product, ProductScan $scan ): bool {
+ if ( ! ProductFields::has_own_price( $product ) ) {
+ return false;
+ }
+ $items = $product instanceof \WC_Product_Variable ? ProductFields::variations( $product ) : array( $product );
+ if ( empty( $items ) ) {
+ return true;
+ }
+ foreach ( $items as $item ) {
+ if ( ! self::has_positive_price( $item ) ) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * A gap, not a missing listing, for a variable product with a priced variation.
+ *
+ * @param \WC_Product $product Product that fails the rule.
+ * @param array<string, array{field:string,effect:string}> $channels The rule's channel map.
+ * @return array<string, array{field:string,effect:string}>
+ */
+ public function channels_for( \WC_Product $product, array $channels ): array {
+ foreach ( ProductFields::variations( $product ) as $variation ) {
+ if ( self::has_positive_price( $variation ) ) {
+ foreach ( $channels as $channel => $spec ) {
+ $channels[ $channel ]['effect'] = self::EFFECT_GAPS;
+ }
+ break;
+ }
+ }
+ return $channels;
+ }
+
+ /**
+ * Whether the product has a positive regular price or a positive active
+ * price. An import that stored only the selling price still has a price.
+ *
+ * @param \WC_Product $product Product or variation.
+ */
+ private static function has_positive_price( \WC_Product $product ): bool {
+ foreach ( array( $product->get_regular_price( 'edit' ), $product->get_price( 'edit' ) ) as $price ) {
+ if ( '' !== (string) $price && (float) $price > 0 ) {
+ return true;
+ }
+ }
+ return false;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/Checks/SimpleCheck.php b/plugins/woocommerce/src/Internal/AgentReadiness/Checks/SimpleCheck.php
new file mode 100644
index 00000000000..05ab5c77e77
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/Checks/SimpleCheck.php
@@ -0,0 +1,246 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\AgentReadiness\Checks;
+
+use Automattic\WooCommerce\Internal\AgentReadiness\FeedChannels;
+use Automattic\WooCommerce\Internal\AgentReadiness\ProductFields;
+use Automattic\WooCommerce\Internal\AgentReadiness\ProductScan;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * A check that is only data: its texts, channel map and a test of one product.
+ * table() lists them; checks that change per product have their own class.
+ *
+ * @since 11.4.0
+ */
+final class SimpleCheck extends AbstractReadinessCheck {
+
+ public const TITLE_MAX_LENGTH = 150;
+
+ /**
+ * Check id.
+ *
+ * @var string
+ */
+ private string $id;
+
+ /**
+ * Merchant-facing label, why it matters, and what to do.
+ *
+ * @var array{label:string,why:string,fix:string}
+ */
+ private array $texts;
+
+ /**
+ * Field and effect by channel.
+ *
+ * @var array<string, array{field:string,effect:string}>
+ */
+ private array $channels;
+
+ /**
+ * Whether a product has the problem.
+ *
+ * @var \Closure(\WC_Product):bool
+ */
+ private \Closure $test;
+
+ /**
+ * Build a check.
+ *
+ * @param string $id Check id.
+ * @param array{label:string,why:string,fix:string} $texts Merchant-facing texts.
+ * @param array<string, array{field:string,effect:string}> $channels Field and effect by channel.
+ * @param \Closure $test Whether a product has the problem: fn( \WC_Product ): bool.
+ */
+ public function __construct( string $id, array $texts, array $channels, \Closure $test ) {
+ $this->id = $id;
+ $this->texts = $texts;
+ $this->channels = $channels;
+ $this->test = $test;
+ }
+
+ /**
+ * The data-only checks, by id.
+ *
+ * @return array<string, self>
+ */
+ public static function table(): array {
+ $checks = array(
+ new self(
+ 'missing_images',
+ array(
+ 'label' => __( 'Products with no main image', 'woocommerce' ),
+ 'why' => __( 'Every channel\'s spec requires an image link, so the product can\'t be listed without one.', 'woocommerce' ),
+ 'fix' => __( 'Add a main product image.', 'woocommerce' ),
+ ),
+ self::fields( array( 'image_url', 'image_link', 'imageLink' ), self::EFFECT_NOT_LISTABLE ),
+ static fn( \WC_Product $product ): bool => 0 === ProductFields::image_id( $product )
+ ),
+ new self(
+ 'missing_product_text',
+ array(
+ 'label' => __( 'Products with no description', 'woocommerce' ),
+ 'why' => __( 'ChatGPT\'s spec requires a description, so the product can\'t be listed there without one. Google and Copilot list it with a warning.', 'woocommerce' ),
+ 'fix' => __( 'Write a description that says what the product is and who it is for.', 'woocommerce' ),
+ ),
+ self::fields( array( 'description', 'description', 'description' ), self::EFFECT_GAPS, FeedChannels::CHATGPT ),
+ static fn( \WC_Product $product ): bool => '' === ProductFields::text( $product )
+ ),
+ new self(
+ 'invalid_gtin',
+ array(
+ 'label' => __( 'Products with an invalid GTIN', 'woocommerce' ),
+ 'why' => __( 'Google\'s spec requires a submitted GTIN to be valid, so the product can\'t be listed there with a wrong one.', 'woocommerce' ),
+ 'fix' => __( 'Correct the GTIN; it must be 8, 12, 13 or 14 digits with a valid check digit. Convert an ISBN-10 to its ISBN-13.', 'woocommerce' ),
+ ),
+ self::fields( array( 'gtin', 'gtin', 'gtin' ), self::EFFECT_GAPS, FeedChannels::GOOGLE ),
+ static fn( \WC_Product $product ): bool => ProductFields::needs_identifiers( $product )
+ && in_array( false, array_map( array( ProductFields::class, 'is_valid_gtin' ), ProductFields::gtins( $product ) ), true )
+ ),
+ new self(
+ 'uncategorised',
+ array(
+ 'label' => __( 'Products with only the default category', 'woocommerce' ),
+ 'why' => __( 'Agents narrow searches by category first; an uncategorised product is harder to find.', 'woocommerce' ),
+ 'fix' => __( 'Assign a category.', 'woocommerce' ),
+ ),
+ self::fields( array( 'product_category', 'product_type', 'productType' ), self::EFFECT_GAPS ),
+ static fn( \WC_Product $product ): bool => empty( array_diff( array_map( 'intval', $product->get_category_ids() ), array( (int) get_option( 'default_product_cat', 0 ) ) ) )
+ ),
+ new self(
+ 'variation_attributes_not_set',
+ array(
+ 'label' => __( 'Variable products with variations not tied to an option', 'woocommerce' ),
+ 'why' => __( 'A variation set to "any" can\'t be listed as a distinct item with its own color or size.', 'woocommerce' ),
+ 'fix' => __( 'Set the options on each variation.', 'woocommerce' ),
+ ),
+ self::fields( array( 'variant_dict', 'color, size', 'color, sizes' ), self::EFFECT_GAPS ),
+ \Closure::fromCallable( array( self::class, 'has_unset_variation_option' ) )
+ ),
+ new self(
+ 'missing_variation_images',
+ array(
+ 'label' => __( 'Variable products with variations missing images', 'woocommerce' ),
+ 'why' => __( 'Each variation lists as its own item; without its own image it shows the parent image, which may not match the color or finish.', 'woocommerce' ),
+ 'fix' => __( 'Add an image to each variation.', 'woocommerce' ),
+ ),
+ self::fields( array( 'image_url', 'image_link', 'imageLink' ), self::EFFECT_GAPS ),
+ static function ( \WC_Product $product ): bool {
+ foreach ( ProductFields::variations( $product ) as $variation ) {
+ if ( (int) $variation->get_image_id( 'edit' ) <= 0 ) {
+ return true;
+ }
+ }
+ return false;
+ }
+ ),
+ new self(
+ 'title_too_long',
+ array(
+ 'label' => __( 'Products with titles over 150 characters', 'woocommerce' ),
+ 'why' => __( 'Google truncates titles over 150 characters; ChatGPT and Copilot cap titles at 150.', 'woocommerce' ),
+ 'fix' => __( 'Shorten the title, keeping the brand and product type first.', 'woocommerce' ),
+ ),
+ self::on_every_channel( 'title', self::EFFECT_GAPS ),
+ static fn( \WC_Product $product ): bool => mb_strlen( $product->get_name() ) > self::TITLE_MAX_LENGTH
+ ),
+ );
+ $by_id = array();
+ foreach ( $checks as $check ) {
+ $by_id[ $check->id() ] = $check;
+ }
+ return $by_id;
+ }
+
+ /**
+ * Feed fields for ChatGPT, Google and Copilot, in that order, with one effect
+ * and optionally one channel where the product can't be listed.
+ *
+ * @param array<int, string> $fields Field per channel.
+ * @param string $effect EFFECT_NOT_LISTABLE or EFFECT_GAPS.
+ * @param string $not_listable_on Channel id whose spec requires the field, or ''.
+ * @return array<string, array{field:string,effect:string}>
+ */
+ private static function fields( array $fields, string $effect, string $not_listable_on = '' ): array {
+ $channels = array();
+ foreach ( array( FeedChannels::CHATGPT, FeedChannels::GOOGLE, FeedChannels::COPILOT ) as $i => $channel ) {
+ $channels[ $channel ] = array(
+ 'field' => $fields[ $i ],
+ 'effect' => $channel === $not_listable_on ? self::EFFECT_NOT_LISTABLE : $effect,
+ );
+ }
+ return $channels;
+ }
+
+ /**
+ * Whether a variable product has no variation attribute, or a variation set to "any".
+ *
+ * @param \WC_Product $product Published product.
+ */
+ private static function has_unset_variation_option( \WC_Product $product ): bool {
+ if ( ! $product instanceof \WC_Product_Variable ) {
+ return false;
+ }
+ $variation_attributes = array_filter(
+ $product->get_attributes(),
+ static fn( $attribute ): bool => $attribute instanceof \WC_Product_Attribute && $attribute->get_variation()
+ );
+ if ( empty( $variation_attributes ) ) {
+ return true;
+ }
+ foreach ( ProductFields::variations( $product ) as $variation ) {
+ if ( in_array( '', array_map( 'strval', $variation->get_attributes() ), true ) ) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Check id.
+ */
+ public function id(): string {
+ return $this->id;
+ }
+
+ /**
+ * Merchant-facing label.
+ */
+ public function label(): string {
+ return $this->texts['label'];
+ }
+
+ /**
+ * Why it matters.
+ */
+ public function why(): string {
+ return $this->texts['why'];
+ }
+
+ /**
+ * What to do about it.
+ */
+ public function fix(): string {
+ return $this->texts['fix'];
+ }
+
+ /**
+ * Field and effect by channel.
+ */
+ public function channels(): array {
+ return $this->channels;
+ }
+
+ /**
+ * Whether the product has this problem.
+ *
+ * @param \WC_Product $product Published product.
+ * @param ProductScan $scan Catalog-wide lookups.
+ */
+ public function fails( \WC_Product $product, ProductScan $scan ): bool {
+ return ( $this->test )( $product );
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/FeedChannels.php b/plugins/woocommerce/src/Internal/AgentReadiness/FeedChannels.php
new file mode 100644
index 00000000000..9557194930a
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/FeedChannels.php
@@ -0,0 +1,45 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\AgentReadiness;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * The AI shopping channels whose public product data specs the readiness
+ * checks test. Google's spec feeds Shopping, AI Mode and Gemini; Microsoft's
+ * feeds Copilot.
+ *
+ * @since 11.4.0
+ */
+final class FeedChannels {
+
+ public const CHATGPT = 'chatgpt';
+ public const GOOGLE = 'google';
+ public const COPILOT = 'copilot';
+
+ /**
+ * Channels by id, in display order.
+ *
+ * @return array<string, array{name:string,spec:string,url:string}>
+ */
+ public static function all(): array {
+ return array(
+ self::CHATGPT => array(
+ 'name' => 'ChatGPT',
+ 'spec' => 'OpenAI product feed spec',
+ 'url' => 'https://developers.openai.com/commerce/specs/feed',
+ ),
+ self::GOOGLE => array(
+ 'name' => 'Google',
+ 'spec' => 'Google Merchant Center product data spec',
+ 'url' => 'https://support.google.com/merchants/answer/7052112',
+ ),
+ self::COPILOT => array(
+ 'name' => 'Microsoft Copilot',
+ 'spec' => 'Microsoft Merchant Center product data',
+ 'url' => 'https://learn.microsoft.com/en-us/advertising/shopping-content/products-resource',
+ ),
+ );
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/ProductFields.php b/plugins/woocommerce/src/Internal/AgentReadiness/ProductFields.php
new file mode 100644
index 00000000000..2122e59c5ee
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/ProductFields.php
@@ -0,0 +1,246 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\AgentReadiness;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Product values as a product feed reads them, shared by the readiness checks.
+ *
+ * @since 11.4.0
+ */
+class ProductFields {
+
+ /**
+ * Characters normalise_gtin() removes: hyphen, ASCII whitespace, no-break
+ * space, figure space, narrow no-break space and the byte order mark.
+ */
+ public const GTIN_SEPARATORS = array( '-', ' ', "\t", "\n", "\r", "\x0B", "\x0C", "\u{00A0}", "\u{2007}", "\u{202F}", "\u{FEFF}" );
+
+ /**
+ * Plain-text description, falling back to the short description, as feeds do.
+ *
+ * @param \WC_Product $product Product.
+ */
+ public static function text( \WC_Product $product ): string {
+ $text = self::plain_text( $product->get_description() );
+ if ( '' === $text ) {
+ $text = self::plain_text( $product->get_short_description() );
+ }
+ return $text;
+ }
+
+ /**
+ * Text left once shortcodes, HTML and entities such as are gone.
+ *
+ * @param string $html Stored description.
+ */
+ private static function plain_text( string $html ): string {
+ $text = html_entity_decode( wp_strip_all_tags( strip_shortcodes( $html ) ), ENT_QUOTES | ENT_HTML5, 'UTF-8' );
+ return trim( (string) preg_replace( '/[\s\x{00A0}\x{2007}\x{202F}\x{FEFF}]+/u', ' ', $text ) );
+ }
+
+ /**
+ * The product's brand names, comma separated, or '' when it has none: the
+ * product_brand taxonomy, the Perfect Brands and YITH brand taxonomies when
+ * registered, then a "brand" attribute.
+ *
+ * @param \WC_Product $product Product, not a variation.
+ */
+ public static function brand( \WC_Product $product ): string {
+ foreach ( array( 'product_brand', 'pwb-brand', 'yith_product_brand' ) as $taxonomy ) {
+ if ( ! taxonomy_exists( $taxonomy ) ) {
+ continue;
+ }
+ $terms = get_the_terms( $product->get_id(), $taxonomy );
+ if ( is_array( $terms ) && ! empty( $terms ) ) {
+ return implode( ', ', wp_list_pluck( $terms, 'name' ) );
+ }
+ }
+ $values = array_map( 'trim', self::attribute_values( $product, array( 'brand' ) ) );
+ return implode( ', ', array_diff( $values, array( '' ) ) );
+ }
+
+ /**
+ * The brand Google for WooCommerce sends for the product when it has its
+ * own value set, or ''. Google reads it; other channels don't.
+ *
+ * @param \WC_Product $product Product, not a variation.
+ */
+ public static function google_feed_brand( \WC_Product $product ): string {
+ return trim( (string) $product->get_meta( '_wc_gla_brand' ) );
+ }
+
+ /**
+ * The product's MPN from Google for WooCommerce or an "MPN" attribute, or ''.
+ *
+ * @param \WC_Product $product Product, not a variation.
+ */
+ public static function mpn( \WC_Product $product ): string {
+ $mpn = trim( (string) $product->get_meta( '_wc_gla_mpn' ) );
+ if ( '' !== $mpn ) {
+ return $mpn;
+ }
+ $values = array_map( 'trim', self::attribute_values( $product, array( 'mpn' ) ) );
+ return implode( ', ', array_diff( $values, array( '' ) ) );
+ }
+
+ /**
+ * Whether the feeds expect a brand and GTIN for this product. Google lets a
+ * product with no assigned identifiers set identifier_exists to false; gift
+ * cards and virtual or downloadable products are treated as such here.
+ *
+ * @param \WC_Product $product Product.
+ */
+ public static function needs_identifiers( \WC_Product $product ): bool {
+ if ( in_array( $product->get_type(), array( 'gift-card', 'pw-gift-card' ), true ) ) {
+ return false;
+ }
+ if ( 'yes' === $product->get_meta( '_gift_card' ) ) {
+ return false;
+ }
+ return ! $product->is_virtual() && ! $product->is_downloadable();
+ }
+
+ /**
+ * Whether the product carries its own price. Bundles, composites and
+ * grouped products are priced from the items the shopper picks.
+ *
+ * @param \WC_Product $product Product.
+ */
+ public static function has_own_price( \WC_Product $product ): bool {
+ return ! in_array( $product->get_type(), array( 'bundle', 'composite', 'grouped' ), true );
+ }
+
+ /**
+ * Published variations of a variable product; empty for other types. Kept
+ * for the last product asked about, since every rule asks about the same
+ * product in turn.
+ *
+ * @param \WC_Product $product Product.
+ * @return array<int, \WC_Product_Variation>
+ */
+ public static function variations( \WC_Product $product ): array {
+ static $last_product = null;
+ static $last_variations = array();
+
+ if ( ! $product instanceof \WC_Product_Variable ) {
+ return array();
+ }
+ if ( $product === $last_product ) {
+ return $last_variations;
+ }
+
+ $variations = array();
+ foreach ( $product->get_children() as $child_id ) {
+ $variation = wc_get_product( (int) $child_id );
+ if ( $variation instanceof \WC_Product_Variation && 'publish' === $variation->get_status() ) {
+ $variations[] = $variation;
+ }
+ }
+ $last_product = $product;
+ $last_variations = $variations;
+ return $variations;
+ }
+
+ /**
+ * Normalised GTINs on the product and its published variations.
+ *
+ * @param \WC_Product $product Product.
+ * @return array<int, string>
+ */
+ public static function gtins( \WC_Product $product ): array {
+ $gtins = array();
+ foreach ( array_merge( array( $product ), self::variations( $product ) ) as $item ) {
+ $gtin = self::normalise_gtin( (string) $item->get_global_unique_id() );
+ if ( '' !== $gtin ) {
+ $gtins[] = $gtin;
+ }
+ }
+ return array_values( array_unique( $gtins ) );
+ }
+
+ /**
+ * Remove the hyphens and spaces, including non-breaking ones, that core
+ * allows inside a GTIN and that imports leave around it. The duplicate
+ * GTIN query strips the same characters.
+ *
+ * @param string $gtin Stored GTIN.
+ */
+ public static function normalise_gtin( string $gtin ): string {
+ return str_replace( self::GTIN_SEPARATORS, '', $gtin );
+ }
+
+ /**
+ * The key two GTINs share when they name the same item: GTIN-8, -12 and -13
+ * are GTIN-14 with leading zeros dropped.
+ *
+ * @param string $gtin Normalised GTIN.
+ */
+ public static function gtin_key( string $gtin ): string {
+ return ltrim( $gtin, '0' );
+ }
+
+ /**
+ * GS1 GTIN-8, -12, -13 or -14 with a valid check digit.
+ *
+ * @see https://support.google.com/merchants/answer/6324461
+ *
+ * @param string $gtin Normalised GTIN.
+ */
+ public static function is_valid_gtin( string $gtin ): bool {
+ if ( ! preg_match( '/^(\d{8}|\d{12,14})$/', $gtin ) ) {
+ return false;
+ }
+
+ $digits = array_map( 'intval', str_split( strrev( substr( $gtin, 0, -1 ) ) ) );
+ $sum = 0;
+ foreach ( $digits as $position => $digit ) {
+ $sum += 0 === $position % 2 ? $digit * 3 : $digit;
+ }
+ return ( 10 - ( $sum % 10 ) ) % 10 === (int) substr( $gtin, -1 );
+ }
+
+ /**
+ * Values of the product's attributes whose name contains one of $names.
+ *
+ * @param \WC_Product $product Product.
+ * @param array<int, string> $names Lower-case name fragments.
+ * @return array<int, string>
+ */
+ public static function attribute_values( \WC_Product $product, array $names ): array {
+ $values = array();
+ foreach ( $product->get_attributes() as $key => $attribute ) {
+ if ( ! $attribute instanceof \WC_Product_Attribute ) {
+ continue;
+ }
+ $label = strtolower( $attribute->get_name() . ' ' . (string) $key );
+ foreach ( $names as $name ) {
+ if ( false === strpos( $label, $name ) ) {
+ continue;
+ }
+ if ( $attribute->is_taxonomy() ) {
+ foreach ( (array) $attribute->get_terms() as $term ) {
+ $values[] = $term->name;
+ }
+ } else {
+ $values = array_merge( $values, $attribute->get_options() );
+ }
+ break;
+ }
+ }
+ return array_values( array_unique( array_map( 'strval', $values ) ) );
+ }
+
+ /**
+ * The featured image's attachment id, or 0 when it is unset or the
+ * attachment no longer exists.
+ *
+ * @param \WC_Product $product Product.
+ */
+ public static function image_id( \WC_Product $product ): int {
+ $image_id = (int) $product->get_image_id();
+ return $image_id > 0 && 'attachment' === get_post_type( $image_id ) ? $image_id : 0;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/ProductScan.php b/plugins/woocommerce/src/Internal/AgentReadiness/ProductScan.php
new file mode 100644
index 00000000000..f4e9ace1299
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/ProductScan.php
@@ -0,0 +1,472 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\AgentReadiness;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * One pass over the published catalog for the readiness checks.
+ *
+ * Walks published simple and variable products in batches, most recently
+ * modified first, tests each against every rule once and tallies product
+ * statuses per channel. Stops after PRODUCT_LIMIT products so the scan stays
+ * inside one request. Products hidden from the shop catalog are the merchant's
+ * choice and other product types are not supported by agents yet, so both are
+ * counted, not checked.
+ *
+ * @since 11.4.0
+ */
+class ProductScan {
+
+ public const BATCH_SIZE = 200;
+
+ public const PRODUCT_LIMIT = 5000;
+
+ public const SUPPORTED_TYPES = array( 'simple', 'variable' );
+
+ public const NOT_LISTABLE = 'not_listable';
+ public const WITH_GAPS = 'with_gaps';
+ public const COMPLETE = 'complete';
+
+ /**
+ * Count and product ids per rule id.
+ *
+ * @var array<string, array{count:int,product_ids:array<int,int>}>
+ */
+ private array $results = array();
+
+ /**
+ * Products tested.
+ *
+ * @var int
+ */
+ private int $products = 0;
+
+ /**
+ * Published products of a supported type hidden from the shop catalog.
+ *
+ * @var int
+ */
+ private int $hidden_from_catalog = 0;
+
+ /**
+ * Published products of a type agents don't support yet.
+ *
+ * @var int
+ */
+ private int $unsupported_types = 0;
+
+ /**
+ * Product statuses by channel id.
+ *
+ * @var array<string, array<string, int>>
+ */
+ private array $statuses = array();
+
+ /**
+ * Normalised GTIN keys carried by more than one published product.
+ *
+ * @var array<string, true>|null
+ */
+ private ?array $shared_gtins = null;
+
+ /**
+ * Whether the catalog has more products than PRODUCT_LIMIT.
+ *
+ * @var bool
+ */
+ private bool $truncated = false;
+
+ /**
+ * Whether the readiness checks test this product.
+ *
+ * @param \WC_Product $product Product.
+ */
+ public static function covers( \WC_Product $product ): bool {
+ return 'publish' === $product->get_status()
+ && ! $product instanceof \WC_Product_Variation
+ && in_array( self::stored_type( $product ), self::SUPPORTED_TYPES, true )
+ && ! in_array( $product->get_catalog_visibility(), array( 'search', 'hidden' ), true );
+ }
+
+ /**
+ * The product type as stored, which the scan query reads. A product whose
+ * type's plugin is inactive loads as simple, but keeps its stored type.
+ *
+ * @param \WC_Product $product Product.
+ */
+ private static function stored_type( \WC_Product $product ): string {
+ $type = \WC_Product_Factory::get_product_type( $product->get_id() );
+ return is_string( $type ) && '' !== $type ? $type : 'simple';
+ }
+
+ /**
+ * Test every covered product against the rules.
+ *
+ * @param array<string, ReadinessRule> $rules Rules by id.
+ * @param array<string, array<string, array{field:string,effect:string}>> $channel_rules Channel map by rule id.
+ * @param int $ids_limit Product ids kept per rule.
+ */
+ public function evaluate( array $rules, array $channel_rules, int $ids_limit ): void {
+ $this->results = array_fill_keys(
+ array_keys( $rules ),
+ array(
+ 'count' => 0,
+ 'product_ids' => array(),
+ )
+ );
+ $this->products = 0;
+ $this->statuses = array();
+ $this->truncated = false;
+ $this->hidden_from_catalog = self::count_published( array( self::supported_type_query(), self::catalog_query( 'IN' ) ) );
+ $this->unsupported_types = self::count_published(
+ array(
+ array(
+ 'taxonomy' => 'product_type',
+ 'field' => 'slug',
+ 'terms' => self::SUPPORTED_TYPES,
+ 'operator' => 'NOT IN',
+ ),
+ array(
+ 'taxonomy' => 'product_type',
+ 'operator' => 'EXISTS',
+ ),
+ )
+ );
+
+ $flush_runtime_cache = wp_cache_supports( 'flush_runtime' );
+ $page = 1;
+ do {
+ $query = new \WP_Query(
+ array(
+ 'post_type' => 'product',
+ 'post_status' => 'publish',
+ 'posts_per_page' => self::BATCH_SIZE,
+ 'paged' => $page,
+ 'fields' => 'ids',
+ 'orderby' => array(
+ 'modified' => 'DESC',
+ 'ID' => 'DESC',
+ ),
+ 'no_found_rows' => true,
+ 'tax_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query
+ self::supported_type_query(),
+ self::catalog_query( 'NOT IN' ),
+ ),
+ )
+ );
+
+ $product_ids = array_map(
+ static function ( $post ): int {
+ return $post instanceof \WP_Post ? $post->ID : (int) $post;
+ },
+ $query->posts
+ );
+ $batch_count = count( $product_ids );
+ self::prime_related( $product_ids );
+ foreach ( $product_ids as $product_id ) {
+ if ( self::PRODUCT_LIMIT === $this->products ) {
+ $this->truncated = true;
+ break 2;
+ }
+ $product = wc_get_product( $product_id );
+ if ( $product ) {
+ $this->record( $product, $rules, $channel_rules, $ids_limit );
+ }
+ }
+
+ // A full-catalog read would otherwise keep every product's post, meta and terms in the request's object cache.
+ if ( $flush_runtime_cache ) {
+ wp_cache_flush_runtime();
+ }
+ ++$page;
+ } while ( self::BATCH_SIZE === $batch_count );
+ }
+
+ /**
+ * Tax query for a supported type. A product with no type term loads as simple.
+ *
+ * @return array<string|int, mixed>
+ */
+ private static function supported_type_query(): array {
+ return array(
+ 'relation' => 'OR',
+ array(
+ 'taxonomy' => 'product_type',
+ 'field' => 'slug',
+ 'terms' => self::SUPPORTED_TYPES,
+ ),
+ array(
+ 'taxonomy' => 'product_type',
+ 'operator' => 'NOT EXISTS',
+ ),
+ );
+ }
+
+ /**
+ * Tax query on the shop catalog: "Search results only" and "Hidden" both
+ * carry the exclude-from-catalog term.
+ *
+ * @param string $operator IN for hidden products, NOT IN for the rest.
+ * @return array<string, mixed>
+ */
+ private static function catalog_query( string $operator ): array {
+ return array(
+ 'taxonomy' => 'product_visibility',
+ 'field' => 'name',
+ 'terms' => array( 'exclude-from-catalog' ),
+ 'operator' => $operator,
+ );
+ }
+
+ /**
+ * Count published products matching a tax query.
+ *
+ * @param array<int, array<string|int, mixed>> $tax_query Tax query clauses.
+ */
+ private static function count_published( array $tax_query ): int {
+ $query = new \WP_Query(
+ array(
+ 'post_type' => 'product',
+ 'post_status' => 'publish',
+ 'posts_per_page' => 1,
+ 'fields' => 'ids',
+ 'tax_query' => $tax_query, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query
+ )
+ );
+ return (int) $query->found_posts;
+ }
+
+ /**
+ * Load the products with their meta and terms, then the featured images
+ * and variations the checks read, one query each instead of one per product.
+ *
+ * @param array<int, int|string> $product_ids Product ids.
+ */
+ public static function prime_related( array $product_ids ): void {
+ global $wpdb;
+ if ( empty( $product_ids ) ) {
+ return;
+ }
+
+ _prime_post_caches( array_map( 'intval', $product_ids ), true, true );
+
+ _prime_post_caches(
+ array_filter(
+ array_map(
+ static function ( $product_id ): int {
+ return (int) get_post_meta( (int) $product_id, '_thumbnail_id', true );
+ },
+ $product_ids
+ )
+ ),
+ false,
+ true
+ );
+ $placeholders = implode( ',', array_fill( 0, count( $product_ids ), '%d' ) );
+ _prime_post_caches(
+ $wpdb->get_col(
+ $wpdb->prepare(
+ // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- One %d placeholder per product.
+ "SELECT ID FROM {$wpdb->posts} WHERE post_type = 'product_variation' AND post_parent IN ({$placeholders})",
+ array_map( 'intval', $product_ids )
+ )
+ ),
+ true,
+ true
+ );
+ }
+
+ /**
+ * Count and product ids for one rule.
+ *
+ * @param string $rule_id Rule id.
+ * @return array{count:int,product_ids:array<int,int>}
+ */
+ public function result( string $rule_id ): array {
+ return $this->results[ $rule_id ] ?? array(
+ 'count' => 0,
+ 'product_ids' => array(),
+ );
+ }
+
+ /**
+ * Products tested.
+ */
+ public function products(): int {
+ return $this->products;
+ }
+
+ /**
+ * Published products of a supported type hidden from the shop catalog, not checked.
+ */
+ public function hidden_from_catalog(): int {
+ return $this->hidden_from_catalog;
+ }
+
+ /**
+ * Published products of a type agents don't support yet, not checked.
+ */
+ public function unsupported_types(): int {
+ return $this->unsupported_types;
+ }
+
+ /**
+ * Per channel, how many products can't be listed there, list with gaps, or list complete.
+ *
+ * @return array<string, array{name:string,spec:string,url:string,listable:int,not_listable:int,with_gaps:int,complete:int}>
+ */
+ public function channel_totals(): array {
+ $channels = array();
+ foreach ( FeedChannels::all() as $id => $channel ) {
+ $counts = array_merge( array_fill_keys( array( self::NOT_LISTABLE, self::WITH_GAPS, self::COMPLETE ), 0 ), $this->statuses[ $id ] ?? array() );
+ $channels[ $id ] = array_merge(
+ $channel,
+ array(
+ 'listable' => $this->products - $counts[ self::NOT_LISTABLE ],
+ 'not_listable' => $counts[ self::NOT_LISTABLE ],
+ 'with_gaps' => $counts[ self::WITH_GAPS ],
+ 'complete' => $counts[ self::COMPLETE ],
+ )
+ );
+ }
+ return $channels;
+ }
+
+ /**
+ * A product's status on each channel from the channel maps of the rules it
+ * fails: not_listable when a failed rule's field is required there,
+ * with_gaps when only gaps apply there, complete otherwise.
+ *
+ * @param array<int, array<string, array{field:string,effect:string}>> $failed_channels Channel map of each failed rule, for this product.
+ * @return array<string, string> Status by channel id.
+ */
+ public static function statuses( array $failed_channels ): array {
+ $statuses = array_fill_keys( array_keys( FeedChannels::all() ), self::COMPLETE );
+ foreach ( $failed_channels as $channels ) {
+ foreach ( $channels as $channel => $spec ) {
+ if ( ! isset( $statuses[ $channel ] ) ) {
+ continue;
+ }
+ if ( ReadinessRule::EFFECT_NOT_LISTABLE === $spec['effect'] ) {
+ $statuses[ $channel ] = self::NOT_LISTABLE;
+ } elseif ( self::COMPLETE === $statuses[ $channel ] ) {
+ $statuses[ $channel ] = self::WITH_GAPS;
+ }
+ }
+ }
+ return $statuses;
+ }
+
+ /**
+ * Whether the scan stopped at PRODUCT_LIMIT with products left.
+ */
+ public function truncated(): bool {
+ return $this->truncated;
+ }
+
+ /**
+ * Whether more than one published product carries this GTIN, counting a
+ * variable product and its variations once. GTINs compare without leading zeros.
+ *
+ * @param string $gtin Normalised GTIN.
+ */
+ public function is_shared_gtin( string $gtin ): bool {
+ if ( null === $this->shared_gtins ) {
+ global $wpdb;
+
+ // Read from postmeta, not wc_product_meta_lookup: imports and direct
+ // meta writes skip both the lookup table and core's uniqueness check.
+ // Only shared GTINs come back, so memory follows the duplicates.
+ // The separators go in as hex literals: a non-ASCII query sends
+ // wpdb into its per-table charset checks.
+ $normalised = 'pm.meta_value';
+ foreach ( ProductFields::GTIN_SEPARATORS as $separator ) {
+ $normalised = "REPLACE( {$normalised}, _utf8mb4 X'" . bin2hex( $separator ) . "', '' )";
+ }
+ // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $normalised is built from constant hex literals.
+ $gtins = (array) $wpdb->get_col(
+ $wpdb->prepare(
+ "
+ SELECT owned.gtin
+ FROM (
+ SELECT DISTINCT IF( p.post_type = %s, p.post_parent, p.ID ) AS owner_id,
+ TRIM( LEADING '0' FROM {$normalised} ) AS gtin
+ FROM {$wpdb->postmeta} pm
+ INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
+ INNER JOIN {$wpdb->posts} owner ON owner.ID = IF( p.post_type = %s, p.post_parent, p.ID )
+ WHERE pm.meta_key = %s
+ AND pm.meta_value != ''
+ AND p.post_type IN ( %s, %s )
+ AND p.post_status = %s
+ AND owner.post_status = %s
+ ) owned
+ GROUP BY owned.gtin
+ HAVING COUNT(*) > 1
+ ",
+ 'product_variation',
+ 'product_variation',
+ '_global_unique_id',
+ 'product',
+ 'product_variation',
+ 'publish',
+ 'publish'
+ )
+ );
+ // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
+
+ $this->shared_gtins = array_fill_keys( array_map( 'strval', $gtins ), true );
+ }
+
+ return isset( $this->shared_gtins[ ProductFields::gtin_key( $gtin ) ] );
+ }
+
+ /**
+ * The rules a product fails, with the channels each failure applies to for this product.
+ * A rule that applies to none of the product's channels is left out.
+ *
+ * @param \WC_Product $product Product.
+ * @param array<string, ReadinessRule> $rules Rules by id.
+ * @param array<string, array<string, array{field:string,effect:string}>> $channel_rules Channel map by rule id.
+ * @return array<string, array<string, array{field:string,effect:string}>>
+ */
+ public function failures( \WC_Product $product, array $rules, array $channel_rules ): array {
+ $failed = array();
+ foreach ( $rules as $id => $rule ) {
+ if ( ! $rule->fails( $product, $this ) ) {
+ continue;
+ }
+ $channels = $rule->channels_for( $product, $channel_rules[ $id ] ?? array() );
+ if ( ! empty( $channels ) ) {
+ $failed[ (string) $id ] = $channels;
+ }
+ }
+ return $failed;
+ }
+
+ /**
+ * Test one product and add it to the results and totals.
+ *
+ * @param \WC_Product $product Product.
+ * @param array<string, ReadinessRule> $rules Rules by id.
+ * @param array<string, array<string, array{field:string,effect:string}>> $channel_rules Channel map by rule id.
+ * @param int $ids_limit Product ids kept per rule.
+ */
+ private function record( \WC_Product $product, array $rules, array $channel_rules, int $ids_limit ): void {
+ $failures = $this->failures( $product, $rules, $channel_rules );
+ foreach ( array_keys( $failures ) as $id ) {
+ $result = $this->result( $id );
+ ++$result['count'];
+ if ( count( $result['product_ids'] ) < $ids_limit ) {
+ $result['product_ids'][] = $product->get_id();
+ }
+ $this->results[ $id ] = $result;
+ }
+
+ ++$this->products;
+ foreach ( self::statuses( array_values( $failures ) ) as $channel => $status ) {
+ $this->statuses[ $channel ][ $status ] = ( $this->statuses[ $channel ][ $status ] ?? 0 ) + 1;
+ }
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/README.md b/plugins/woocommerce/src/Internal/AgentReadiness/README.md
new file mode 100644
index 00000000000..5f88fa62c1d
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/README.md
@@ -0,0 +1,121 @@
+# Agent readiness
+
+Checks published simple and variable products against the product data specs of AI shopping channels and reports what keeps them from being listed or found. Behind the experimental `agent_readiness` feature flag (disabled by default, not shown on the Features screen).
+
+## What the checks read
+
+The checks read product data and store settings, because most gaps are empty fields only the merchant can fill, and a check can name the product and field. When data is filled in but a store output (UCP catalog, product structured data) doesn't send it, that is a bug fixed in that output's code, not a check. What a store can't see from its own data, such as crawler blocks at a CDN, `llms.txt` or page markup from themes and plugins, is left to an outside scan of the store's public pages.
+
+## Channels
+
+| Id | Channel | Spec |
+| --- | --- | --- |
+| `chatgpt` | ChatGPT | [OpenAI product feed spec](https://developers.openai.com/commerce/specs/feed) |
+| `google` | Google (Shopping, AI Mode, Gemini) | [Google Merchant Center product data spec](https://support.google.com/merchants/answer/7052112) |
+| `copilot` | Microsoft Copilot | [Microsoft Merchant Center product data](https://learn.microsoft.com/en-us/advertising/shopping-content/products-resource) |
+
+## Checks
+
+Each check is a `ReadinessRule` in `Checks/`: a row in `SimpleCheck::table()` when it is only data, or its own class when its result changes per product (price, brand, GTIN, duplicate GTIN, image size). Per channel it names the feed field it tests and an effect:
+
+- `not_listable`: the channel's spec requires the field, so the product can't be listed there without it.
+- `gaps`: the product lists, but is harder to find or shows a warning.
+
+A channel whose spec does not ask for the field is left out of the check's map.
+
+| Id | ChatGPT | Google | Copilot |
+| --- | --- | --- | --- |
+| `missing_regular_price` | price, not_listable | price, not_listable | price, not_listable |
+| `missing_images` | image_url, not_listable | image_link, not_listable | imageLink, not_listable |
+| `missing_product_text` | description, not_listable | description, gaps | description, gaps |
+| `missing_brand` | brand, not_listable | brand, gaps | brand, gaps |
+| `invalid_gtin` | gtin, gaps | gtin, not_listable | gtin, gaps |
+| `missing_gtin` | gtin, gaps | gtin, gaps | gtin, gaps |
+| `duplicate_gtin` | | gtin, gaps | |
+| `image_too_small` | | image_link, gaps (not_listable from 2027-01-31) | |
+| `uncategorised` | product_category, gaps | product_type, gaps | productType, gaps |
+| `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 |
+
+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.
+
+Two Google exceptions, per product: `missing_brand` leaves Google out when Google for WooCommerce sends its own brand (`_wc_gla_brand`), and `missing_gtin` leaves Google out when the product has a brand and an MPN (`_wc_gla_mpn` or an "MPN" attribute). Brands set through Google for WooCommerce attribute mapping rules are not read yet.
+
+## Scope
+
+Only published simple and variable products are checked, the types agents support today. Two groups are counted instead of checked, so the merchant sees them without being asked to fix them:
+
+- 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 Plain permalinks (no `/.well-known/ucp`) hide the catalog from UCP agents, and guest checkout off stops agents placing orders. Each condition's `blocks` says which, so a reader can tell a hidden store from one agents can't buy from.
+
+## Report format
+
+`AgentReadiness::get_report()` returns:
+
+```php
+array(
+ 'generated_at' => '2026-10-28T12:00:00+00:00',
+ 'products' => 120, // Published simple and variable products checked.
+ 'hidden_from_catalog' => 4, // Not checked: catalog visibility "Search results only" or "Hidden", the merchant's choice.
+ 'unsupported_types' => 6, // Not checked: grouped, external, subscription, bundle and other types agents don't support yet.
+ 'store_checks' => array(
+ // blocks: 'listing' (every product on every channel), 'ucp' (the catalog for UCP agents) or 'checkout' (agent orders).
+ array( 'id' => 'launched', 'ok' => true, 'blocks' => 'listing', 'label' => '…', 'why' => '…', 'fix' => '…' ),
+ ),
+ 'channels' => array(
+ 'chatgpt' => array(
+ 'name' => 'ChatGPT',
+ 'spec' => 'OpenAI product feed spec',
+ 'url' => 'https://…',
+ 'listable' => 100, // products - not_listable.
+ 'not_listable' => 20,
+ 'with_gaps' => 60,
+ 'complete' => 40,
+ ),
+ ),
+ 'checks' => array(
+ array(
+ 'id' => 'missing_brand',
+ 'label' => 'Products with no brand',
+ 'why' => '…',
+ 'fix' => '…',
+ 'channels' => array( 'chatgpt' => array( 'field' => 'brand', 'effect' => 'not_listable' ) ),
+ 'count' => 20,
+ 'product_ids' => array( 12, 34 ), // Up to 10, most recently modified first.
+ 'fix_url' => 'https://example.com/wp-admin/edit.php?post_type=product',
+ ),
+ ),
+ 'truncated' => false, // True when the scan stopped at 5,000 products.
+)
+```
+
+## One product
+
+`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.
+
+The same shape is meant for every consumer: this Status tab, an outside scan of a store, and extensions that rank the findings by sales or offer fixes. Check and channel ids are stable keys.
+
+The scan walks published simple and variable products shown in the shop catalog in batches of 200, most recently modified first, primes meta, terms, images and variations per batch, and stops at 5,000 products. The Status tab runs it on request and keeps the result in a transient for an hour.
+
+## 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.
+
+## Product feeds
+
+`AgentReadiness::get_failing_check_ids( $product )` returns the ids of the checks a product fails, so a feed integration can reuse them. It judges a variation by its parent, as the scan counts it, and returns an empty list for products the scan leaves out.
+
+Core's UCP catalog mapper (`src/Internal/ProductFeed/Mapping/UcpProductMapper.php`) sends each product's or variation's own valid GTIN as a barcode, so `missing_gtin` fails a variable product when any published variation has no GTIN of its own. UCP has no brand field, so `missing_brand` applies to the other channels, not UCP.
+
+## Trying it
+
+```sh
+wp option update woocommerce_feature_agent_readiness_enabled yes
+```
+
+Then open WooCommerce > Status > Agent readiness and select "Run check".
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/ReadinessRule.php b/plugins/woocommerce/src/Internal/AgentReadiness/ReadinessRule.php
new file mode 100644
index 00000000000..dd3baca3ab5
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/ReadinessRule.php
@@ -0,0 +1,68 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\AgentReadiness;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * A readiness check: one product data problem tested against the product data
+ * specs of AI shopping channels.
+ *
+ * Per channel a rule names the feed field it tests and whether the channel's
+ * spec requires it (not_listable) or the product lists with a gap (gaps). A
+ * channel whose spec does not ask for the field is left out.
+ *
+ * @since 11.4.0
+ */
+interface ReadinessRule {
+
+ public const EFFECT_NOT_LISTABLE = 'not_listable';
+ public const EFFECT_GAPS = 'gaps';
+
+ /**
+ * Stable id, used as the key in the report and the rules filter.
+ */
+ public function id(): string;
+
+ /**
+ * Merchant-facing label naming the problem.
+ */
+ public function label(): string;
+
+ /**
+ * Why it matters for shopping agents.
+ */
+ public function why(): string;
+
+ /**
+ * What the merchant should do about it.
+ */
+ public function fix(): string;
+
+ /**
+ * Default field and effect by FeedChannels id.
+ *
+ * @return array<string, array{field:string,effect:string}>
+ */
+ public function channels(): array;
+
+ /**
+ * Field and effect by channel for one product that fails the rule. Usually
+ * the given map; a rule may soften the effect for a product that still
+ * lists in part.
+ *
+ * @param \WC_Product $product Product that fails the rule.
+ * @param array<string, array{field:string,effect:string}> $channels The rule's channel map, after the rules filter.
+ * @return array<string, array{field:string,effect:string}>
+ */
+ public function channels_for( \WC_Product $product, array $channels ): array;
+
+ /**
+ * Whether the product has this problem.
+ *
+ * @param \WC_Product $product Published product.
+ * @param ProductScan $scan Catalog-wide lookups.
+ */
+ public function fails( \WC_Product $product, ProductScan $scan ): bool;
+}
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/StatusTab.php b/plugins/woocommerce/src/Internal/AgentReadiness/StatusTab.php
new file mode 100644
index 00000000000..091d4441551
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/StatusTab.php
@@ -0,0 +1,330 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\AgentReadiness;
+
+use Automattic\WooCommerce\Internal\Features\FeaturesController;
+use Automattic\WooCommerce\Internal\RegisterHooksInterface;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * The "Agent readiness" tab under WooCommerce > Status, shown only when the
+ * agent_readiness feature is enabled. The scan runs when the merchant asks for
+ * it and the report is kept for an hour.
+ *
+ * @since 11.4.0
+ */
+class StatusTab implements RegisterHooksInterface {
+
+ public const FEATURE_ID = 'agent_readiness';
+
+ public const TAB = 'agent-readiness';
+
+ private const NONCE_ACTION = 'wc_agent_readiness_run';
+
+ /**
+ * Features controller.
+ *
+ * @var FeaturesController
+ */
+ private FeaturesController $features_controller;
+
+ /**
+ * Readiness service.
+ *
+ * @var AgentReadiness
+ */
+ private AgentReadiness $agent_readiness;
+
+ /**
+ * Initialize dependencies.
+ *
+ * @internal
+ *
+ * @param FeaturesController $features_controller Features controller.
+ * @param AgentReadiness $agent_readiness Readiness service.
+ */
+ final public function init( FeaturesController $features_controller, AgentReadiness $agent_readiness ): void {
+ $this->features_controller = $features_controller;
+ $this->agent_readiness = $agent_readiness;
+ }
+
+ /**
+ * Register hooks. The feature check waits for init, since feature definitions load translations.
+ */
+ public function register(): void {
+ add_action( 'init', array( $this, 'handle_init' ) );
+ }
+
+ /**
+ * Add the tab when the feature is enabled.
+ *
+ * @internal
+ */
+ public function handle_init(): void {
+ if ( ! is_admin() || ! $this->features_controller->feature_is_enabled( self::FEATURE_ID ) ) {
+ return;
+ }
+ add_filter( 'woocommerce_admin_status_tabs', array( $this, 'handle_woocommerce_admin_status_tabs' ) );
+ add_action( 'woocommerce_admin_status_content_' . self::TAB, array( $this, 'render' ) );
+ add_action( 'load-woocommerce_page_wc-status', array( $this, 'handle_run_request' ) );
+ }
+
+ /**
+ * Add the tab to the status page.
+ *
+ * @internal
+ *
+ * @param mixed $tabs Tab labels by slug.
+ * @return mixed
+ */
+ public function handle_woocommerce_admin_status_tabs( $tabs ) {
+ if ( ! is_array( $tabs ) ) {
+ return $tabs;
+ }
+ $tabs[ self::TAB ] = __( 'Agent readiness', 'woocommerce' );
+ return $tabs;
+ }
+
+ /**
+ * Run the scan when the "Run check" form is submitted, then redirect back to the tab.
+ *
+ * @internal
+ */
+ public function handle_run_request(): void {
+ if ( ! isset( $_POST['wc_agent_readiness_run'] ) ) {
+ return;
+ }
+ check_admin_referer( self::NONCE_ACTION );
+ if ( ! current_user_can( 'manage_woocommerce' ) ) {
+ wp_die( esc_html__( 'You do not have permission to run this check.', 'woocommerce' ), 403 );
+ }
+ $this->agent_readiness->refresh_report();
+ wp_safe_redirect( $this->tab_url() );
+ exit;
+ }
+
+ /**
+ * Render the tab.
+ *
+ * @internal
+ */
+ public function render(): void {
+ $report = $this->agent_readiness->get_cached_report();
+ ?>
+ <h2><?php esc_html_e( 'Agent readiness', 'woocommerce' ); ?></h2>
+ <p><?php esc_html_e( 'Checks your products against the product data specs of AI shopping channels, so agents such as ChatGPT, Google and Copilot can list and recommend them.', 'woocommerce' ); ?></p>
+ <form method="post" action="<?php echo esc_url( $this->tab_url() ); ?>">
+ <?php wp_nonce_field( self::NONCE_ACTION ); ?>
+ <p>
+ <button type="submit" name="wc_agent_readiness_run" value="1" class="button button-primary"><?php esc_html_e( 'Run check', 'woocommerce' ); ?></button>
+ <?php if ( null !== $report && ! empty( $report['generated_at'] ) ) : ?>
+ <span class="description">
+ <?php
+ /* translators: %s: how long ago the check ran, e.g. "5 mins". */
+ echo esc_html( sprintf( __( 'Last run %s ago.', 'woocommerce' ), human_time_diff( (int) strtotime( (string) $report['generated_at'] ) ) ) );
+ ?>
+ </span>
+ <?php endif; ?>
+ </p>
+ </form>
+ <?php
+ if ( null === $report ) {
+ echo '<p>' . esc_html__( 'No check has run yet.', 'woocommerce' ) . '</p>';
+ return;
+ }
+
+ $this->render_store_checks( $report['store_checks'] ?? array() );
+ if ( ! empty( $report['truncated'] ) ) {
+ echo '<div class="notice notice-info inline"><p>';
+ /* translators: %d: number of products checked. */
+ echo esc_html( sprintf( __( 'Only the %d most recently updated products were checked.', 'woocommerce' ), ProductScan::PRODUCT_LIMIT ) );
+ echo '</p></div>';
+ }
+ $this->render_channels( $report['channels'] ?? array(), (int) ( $report['products'] ?? 0 ) );
+ $this->render_not_checked( (int) ( $report['hidden_from_catalog'] ?? 0 ), (int) ( $report['unsupported_types'] ?? 0 ) );
+ $this->render_checks( $report['checks'] ?? array(), $report['channels'] ?? array() );
+ }
+
+ /**
+ * Store-wide problems, shown first because they affect every product.
+ *
+ * @param array $store_checks Store checks from the report.
+ */
+ private function render_store_checks( array $store_checks ): void {
+ $failing = array_filter(
+ $store_checks,
+ static function ( $check ) {
+ return empty( $check['ok'] );
+ }
+ );
+ if ( empty( $failing ) ) {
+ return;
+ }
+ echo '<h3>' . esc_html__( 'Store-wide problems', 'woocommerce' ) . '</h3>';
+ foreach ( $failing as $check ) {
+ printf(
+ '<div class="notice notice-error inline"><p><strong>%1$s</strong> %2$s %3$s</p></div>',
+ esc_html( (string) $check['label'] ),
+ esc_html( (string) $check['why'] ),
+ esc_html( (string) $check['fix'] )
+ );
+ }
+ }
+
+ /**
+ * Per-channel product counts.
+ *
+ * @param array $channels Channels from the report.
+ * @param int $products Products checked.
+ */
+ private function render_channels( array $channels, int $products ): void {
+ echo '<h3>' . esc_html__( 'Channels', 'woocommerce' ) . '</h3>';
+ /* translators: %d: number of products checked. */
+ echo '<p>' . esc_html( sprintf( _n( '%d published product checked.', '%d published products checked.', $products, 'woocommerce' ), $products ) ) . '</p>';
+ ?>
+ <table class="widefat striped">
+ <thead>
+ <tr>
+ <th><?php esc_html_e( 'Channel', 'woocommerce' ); ?></th>
+ <th><?php esc_html_e( 'Can be listed', 'woocommerce' ); ?></th>
+ <th><?php esc_html_e( 'Missing a required field', 'woocommerce' ); ?></th>
+ <th><?php esc_html_e( 'Listed with gaps', 'woocommerce' ); ?></th>
+ <th><?php esc_html_e( 'Complete', 'woocommerce' ); ?></th>
+ </tr>
+ </thead>
+ <tbody>
+ <?php foreach ( $channels as $channel ) : ?>
+ <tr>
+ <td><a href="<?php echo esc_url( (string) $channel['url'] ); ?>" target="_blank" rel="noopener noreferrer"><?php echo esc_html( (string) $channel['name'] ); ?></a><br /><span class="description"><?php echo esc_html( (string) $channel['spec'] ); ?></span></td>
+ <td><?php echo esc_html( (string) (int) $channel['listable'] ); ?></td>
+ <td><?php echo esc_html( (string) (int) $channel['not_listable'] ); ?></td>
+ <td><?php echo esc_html( (string) (int) $channel['with_gaps'] ); ?></td>
+ <td><?php echo esc_html( (string) (int) $channel['complete'] ); ?></td>
+ </tr>
+ <?php endforeach; ?>
+ </tbody>
+ </table>
+ <?php
+ }
+
+ /**
+ * Published products the scan counts but doesn't check.
+ *
+ * @param int $hidden Products hidden from the shop catalog.
+ * @param int $unsupported Products of a type agents don't support yet.
+ */
+ private function render_not_checked( int $hidden, int $unsupported ): void {
+ $lines = array();
+ if ( $hidden > 0 ) {
+ /* translators: %d: number of products. */
+ $lines[] = sprintf( __( 'Hidden from your catalog: %d', 'woocommerce' ), $hidden );
+ }
+ if ( $unsupported > 0 ) {
+ /* translators: %d: number of products. */
+ $lines[] = sprintf( __( 'Product type not supported yet: %d', 'woocommerce' ), $unsupported );
+ }
+ if ( empty( $lines ) ) {
+ return;
+ }
+ echo '<p class="description">' . esc_html__( 'Not checked, because agents don\'t see these products:', 'woocommerce' ) . '</p><ul>';
+ foreach ( $lines as $line ) {
+ echo '<li>' . esc_html( $line ) . '</li>';
+ }
+ echo '</ul>';
+ }
+
+ /**
+ * Each failing check with its channel tags and example products.
+ *
+ * @param array $checks Checks from the report.
+ * @param array $channels Channels from the report.
+ */
+ private function render_checks( array $checks, array $channels ): void {
+ $failing = array_filter(
+ $checks,
+ static function ( $check ) {
+ return (int) ( $check['count'] ?? 0 ) > 0;
+ }
+ );
+ echo '<h3>' . esc_html__( 'Product checks', 'woocommerce' ) . '</h3>';
+ if ( empty( $failing ) ) {
+ echo '<p>' . esc_html__( 'Every product passed every check.', 'woocommerce' ) . '</p>';
+ return;
+ }
+ ?>
+ <table class="widefat striped">
+ <thead>
+ <tr>
+ <th><?php esc_html_e( 'Problem', 'woocommerce' ); ?></th>
+ <th><?php esc_html_e( 'Channels', 'woocommerce' ); ?></th>
+ <th><?php esc_html_e( 'Products', 'woocommerce' ); ?></th>
+ <th><?php esc_html_e( 'Examples', 'woocommerce' ); ?></th>
+ </tr>
+ </thead>
+ <tbody>
+ <?php foreach ( $failing as $check ) : ?>
+ <tr>
+ <td>
+ <strong><?php echo esc_html( (string) $check['label'] ); ?></strong>
+ <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 esc_html( (string) (int) $check['count'] ); ?></td>
+ <td><?php echo wp_kses_post( implode( '<br />', $this->product_links( (array) $check['product_ids'] ) ) ); ?></td>
+ </tr>
+ <?php endforeach; ?>
+ </tbody>
+ </table>
+ <?php
+ }
+
+ /**
+ * "Required by X" or "Harder to find on X" per channel.
+ *
+ * @param array $check_channels Field and effect by channel id.
+ * @param array $channels Channels from the report.
+ * @return array<int, string> Escaped HTML.
+ */
+ private function channel_tags( array $check_channels, array $channels ): array {
+ $tags = array();
+ foreach ( $check_channels as $channel_id => $spec ) {
+ $name = (string) ( $channels[ $channel_id ]['name'] ?? $channel_id );
+ if ( ReadinessRule::EFFECT_NOT_LISTABLE === ( $spec['effect'] ?? '' ) ) {
+ /* translators: %s: channel name, e.g. ChatGPT. */
+ $tags[] = '<mark class="error">' . esc_html( sprintf( __( 'Required by %s', 'woocommerce' ), $name ) ) . '</mark>';
+ } else {
+ /* translators: %s: channel name, e.g. ChatGPT. */
+ $tags[] = esc_html( sprintf( __( 'Harder to find on %s', 'woocommerce' ), $name ) );
+ }
+ }
+ return $tags;
+ }
+
+ /**
+ * Edit links for the example products that still exist.
+ *
+ * @param array $product_ids Product ids.
+ * @return array<int, string> Escaped HTML.
+ */
+ private function product_links( array $product_ids ): array {
+ $links = array();
+ foreach ( array_slice( $product_ids, 0, AgentReadiness::PRODUCT_IDS_LIMIT ) as $product_id ) {
+ $url = get_edit_post_link( (int) $product_id, 'raw' );
+ if ( ! $url ) {
+ continue;
+ }
+ $links[] = '<a href="' . esc_url( $url ) . '">' . esc_html( get_the_title( (int) $product_id ) ) . '</a>';
+ }
+ return $links;
+ }
+
+ /**
+ * URL of this tab.
+ */
+ private function tab_url(): string {
+ return admin_url( 'admin.php?page=wc-status&tab=' . self::TAB );
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/StoreConditions.php b/plugins/woocommerce/src/Internal/AgentReadiness/StoreConditions.php
new file mode 100644
index 00000000000..a5acb976260
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/StoreConditions.php
@@ -0,0 +1,100 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\AgentReadiness;
+
+use Automattic\WooCommerce\Internal\ComingSoon\ComingSoonHelper;
+use Automattic\WooCommerce\StoreApi\Utilities\UcpUtils;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Store settings that stop agents finding or buying the store's products,
+ * whatever the product data. `ok` says whether the store is clear of the
+ * problem the label names. `blocks` says what a failing condition stops:
+ * `listing` hides every product on every channel, `ucp` hides the catalog
+ * from agents that use UCP, `checkout` stops agents placing orders.
+ *
+ * @since 11.4.0
+ */
+class StoreConditions {
+
+ /**
+ * Coming soon mode helper.
+ *
+ * @var ComingSoonHelper
+ */
+ private ComingSoonHelper $coming_soon_helper;
+
+ /**
+ * Initialize dependencies.
+ *
+ * @internal
+ *
+ * @param ComingSoonHelper $coming_soon_helper Coming soon mode helper.
+ */
+ final public function init( ComingSoonHelper $coming_soon_helper ): void {
+ $this->coming_soon_helper = $coming_soon_helper;
+ }
+
+ /**
+ * Every condition with its current state.
+ *
+ * @return array<int, array{id:string,ok:bool,blocks:string,label:string,why:string,fix:string}>
+ */
+ public function all(): array {
+ global $wp_rewrite;
+
+ return array(
+ array(
+ 'id' => 'launched',
+ 'ok' => $this->coming_soon_helper->is_site_live(),
+ 'blocks' => 'listing',
+ 'label' => __( 'Store is in coming soon mode', 'woocommerce' ),
+ 'why' => __( 'Visitors, crawlers and shopping agents see a holding page instead of your products.', 'woocommerce' ),
+ 'fix' => __( 'Launch the store under WooCommerce > Settings > Site visibility.', 'woocommerce' ),
+ ),
+ array(
+ 'id' => 'search_visible',
+ 'ok' => '0' !== (string) get_option( 'blog_public', '1' ),
+ 'blocks' => 'listing',
+ 'label' => __( 'Store is hidden from search engines', 'woocommerce' ),
+ 'why' => __( '"Discourage search engines" asks crawlers, including AI shopping crawlers, to skip your store.', 'woocommerce' ),
+ 'fix' => __( 'Clear "Discourage search engines from indexing this site" under Settings > Reading.', 'woocommerce' ),
+ ),
+ array(
+ 'id' => 'ucp_on',
+ 'ok' => UcpUtils::is_enabled(),
+ 'blocks' => 'ucp',
+ 'label' => __( 'Universal Commerce Protocol (UCP) is off', 'woocommerce' ),
+ 'why' => __( 'Shopping agents that use UCP cannot read your catalog.', 'woocommerce' ),
+ 'fix' => __( 'Turn on agentic commerce in the extension that connects your store to AI agents.', 'woocommerce' ),
+ ),
+ array(
+ 'id' => 'pretty_permalinks',
+ 'ok' => $wp_rewrite->using_permalinks(),
+ 'blocks' => 'ucp',
+ 'label' => __( 'Permalinks are set to Plain', 'woocommerce' ),
+ 'why' => __( 'Your store does not serve the UCP discovery file at /.well-known/ucp, so agents cannot find your catalog and checkout.', 'woocommerce' ),
+ 'fix' => __( 'Choose any permalink structure other than Plain under Settings > Permalinks.', 'woocommerce' ),
+ ),
+ array(
+ 'id' => 'guest_checkout',
+ 'ok' => ! $this->registration_required(),
+ 'blocks' => 'checkout',
+ 'label' => __( 'Guest checkout is off', 'woocommerce' ),
+ 'why' => __( 'Shopping agents check out for shoppers without an account on your store, so they cannot place orders.', 'woocommerce' ),
+ 'fix' => __( 'Tick "Enable guest checkout" under WooCommerce > Settings > Accounts & Privacy.', 'woocommerce' ),
+ ),
+ );
+ }
+
+ /**
+ * Whether checkout requires an account, as WC_Checkout::is_registration_required()
+ * answers it, without starting the checkout in a background scan.
+ */
+ private function registration_required(): bool {
+ // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment -- Core's hook, documented in WC_Checkout.
+ return (bool) apply_filters( 'woocommerce_checkout_registration_required', 'yes' !== get_option( 'woocommerce_enable_guest_checkout' ) );
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Features/FeaturesController.php b/plugins/woocommerce/src/Internal/Features/FeaturesController.php
index c80529bbb65..d2f470254a8 100644
--- a/plugins/woocommerce/src/Internal/Features/FeaturesController.php
+++ b/plugins/woocommerce/src/Internal/Features/FeaturesController.php
@@ -662,6 +662,19 @@ class FeaturesController {
'skip_compatibility_checks' => true,
'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
),
+ 'agent_readiness' => array(
+ 'name' => __( 'Agent readiness', 'woocommerce' ),
+ 'description' => __(
+ 'Check products against the product data specs of AI shopping channels under WooCommerce > Status.',
+ 'woocommerce'
+ ),
+ 'enabled_by_default' => false,
+ // Hidden while it is a preview.
+ 'disable_ui' => true,
+ 'is_experimental' => true,
+ 'skip_compatibility_checks' => true,
+ 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
+ ),
'destroy-empty-sessions' => array(
'name' => __( 'Clear Customer Sessions When Empty', 'woocommerce' ),
'description' => __(
diff --git a/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/AgentReadinessTest.php b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/AgentReadinessTest.php
new file mode 100644
index 00000000000..a155787b059
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/AgentReadinessTest.php
@@ -0,0 +1,274 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\AgentReadiness;
+
+use Automattic\WooCommerce\Internal\AgentReadiness\AgentReadiness;
+use Automattic\WooCommerce\Internal\AgentReadiness\ReadinessRule;
+use Automattic\WooCommerce\Internal\AgentReadiness\StoreConditions;
+use Automattic\WooCommerce\Internal\ComingSoon\ComingSoonHelper;
+use WC_Helper_Product;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the agent readiness report.
+ */
+class AgentReadinessTest extends WC_Unit_Test_Case {
+
+ /**
+ * The System Under Test.
+ *
+ * @var AgentReadiness
+ */
+ private $sut;
+
+ /**
+ * Set up test fixtures.
+ */
+ public function setUp(): void {
+ parent::setUp();
+ $store_conditions = new StoreConditions();
+ $store_conditions->init( new ComingSoonHelper() );
+ $this->sut = new AgentReadiness();
+ $this->sut->init( $store_conditions );
+ }
+
+ /**
+ * @testdox The report has the shared shape with store checks, channel totals and checks.
+ */
+ public function test_report_shape(): void {
+ $product = WC_Helper_Product::create_simple_product();
+
+ $report = $this->sut->get_report();
+
+ $this->assertSame( array( 'generated_at', 'products', 'hidden_from_catalog', 'unsupported_types', 'store_checks', 'channels', 'checks', 'truncated' ), array_keys( $report ) );
+ $this->assertFalse( $report['truncated'] );
+ $this->assertSame( array( 'id', 'ok', 'blocks', 'label', 'why', 'fix' ), array_keys( $report['store_checks'][0] ) );
+ $this->assertSame( array( 'chatgpt', 'google', 'copilot' ), array_keys( $report['channels'] ) );
+ $this->assertSame(
+ array( 'name', 'spec', 'url', 'listable', 'not_listable', 'with_gaps', 'complete' ),
+ array_keys( $report['channels']['chatgpt'] )
+ );
+
+ $checks = array_column( $report['checks'], null, 'id' );
+ $this->assertSame(
+ array( 'id', 'label', 'why', 'fix', 'channels', 'count', 'product_ids', 'fix_url' ),
+ array_keys( $checks['missing_brand'] )
+ );
+ $this->assertSame( 1, $checks['missing_brand']['count'] );
+ $this->assertSame( array( $product->get_id() ), $checks['missing_brand']['product_ids'] );
+ $this->assertSame( ReadinessRule::EFFECT_NOT_LISTABLE, $checks['missing_brand']['channels']['chatgpt']['effect'] );
+
+ $this->assertSame( 1, $report['products'] );
+ $this->assertSame( 1, $report['channels']['chatgpt']['not_listable'], 'No brand and no image keep the product off ChatGPT' );
+ $this->assertSame( 0, $report['channels']['chatgpt']['listable'] );
+ }
+
+ /**
+ * @testdox The rules filter can drop a check and change an effect; invalid entries are ignored.
+ */
+ public function test_rules_filter(): void {
+ WC_Helper_Product::create_simple_product();
+ add_filter(
+ 'woocommerce_agent_readiness_rules',
+ static function ( $rules ) {
+ unset( $rules['missing_brand'] );
+ $rules['missing_gtin']['google']['effect'] = 'not_listable';
+ $rules['missing_gtin']['bing'] = array(
+ 'field' => 'gtin',
+ 'effect' => 'gaps',
+ );
+ $rules['title_too_long']['google']['effect'] = 'rejected';
+ $rules['uncategorised'] = array(
+ 'bing' => array(
+ 'field' => 'category',
+ 'effect' => 'gaps',
+ ),
+ );
+ return $rules;
+ }
+ );
+
+ $report = $this->sut->get_report();
+ $checks = array_column( $report['checks'], null, 'id' );
+
+ $this->assertArrayNotHasKey( 'missing_brand', $checks );
+ $this->assertSame( ReadinessRule::EFFECT_NOT_LISTABLE, $checks['missing_gtin']['channels']['google']['effect'] );
+ $this->assertArrayNotHasKey( 'bing', $checks['missing_gtin']['channels'] );
+ $this->assertArrayNotHasKey( 'google', $checks['title_too_long']['channels'] );
+ $this->assertArrayNotHasKey( 'uncategorised', $checks, 'A check with no valid channel left does not run' );
+ }
+
+ /**
+ * @testdox The cached report is stored by refresh_report.
+ */
+ public function test_refresh_report_caches(): void {
+ $this->assertNull( $this->sut->get_cached_report() );
+
+ $report = $this->sut->refresh_report();
+
+ $this->assertSame( $report, $this->sut->get_cached_report() );
+ }
+
+ /**
+ * @testdox get_failing_check_ids returns the ids of the checks a product fails.
+ */
+ public function test_get_failing_check_ids(): void {
+ $product = WC_Helper_Product::create_simple_product( true, array( 'description' => 'A sturdy mug.' ) );
+
+ $issues = $this->sut->get_failing_check_ids( $product );
+
+ $this->assertContains( 'missing_brand', $issues );
+ $this->assertContains( 'missing_images', $issues );
+ $this->assertNotContains( 'missing_product_text', $issues );
+ $this->assertNotContains( 'missing_regular_price', $issues );
+ }
+
+ /**
+ * @testdox get_failing_check_ids judges a variation by its parent and skips products the scan leaves out.
+ */
+ public function test_get_failing_check_ids_follows_scan_coverage(): void {
+ $variable = WC_Helper_Product::create_variation_product();
+ $variation = wc_get_product( $variable->get_children()[0] );
+ $grouped = WC_Helper_Product::create_grouped_product();
+ $draft = WC_Helper_Product::create_simple_product( true, array( 'status' => 'draft' ) );
+
+ $this->assertSame( $this->sut->get_failing_check_ids( $variable ), $this->sut->get_failing_check_ids( $variation ), 'A variation should get its parent\'s issues' );
+ $this->assertContains( 'missing_brand', $this->sut->get_failing_check_ids( $variation ) );
+ $this->assertSame( array(), $this->sut->get_failing_check_ids( $grouped ), 'Grouped products are not scanned' );
+ $this->assertSame( array(), $this->sut->get_failing_check_ids( $draft ), 'Unpublished products are not scanned' );
+
+ $hidden = WC_Helper_Product::create_variation_product();
+ $hidden->set_catalog_visibility( 'hidden' );
+ $hidden->save();
+ $this->assertSame( array(), $this->sut->get_failing_check_ids( wc_get_product( $hidden->get_children()[0] ) ), 'Nor a hidden product\'s variations' );
+ }
+
+ /**
+ * @testdox check_product gives each failing check's channel map for that product and its status per channel.
+ */
+ public function test_check_product(): void {
+ $product = WC_Helper_Product::create_simple_product( true, array( 'description' => 'A sturdy mug.' ) );
+ $product->update_meta_data( '_wc_gla_brand', 'Acme' );
+ $product->save();
+
+ $result = $this->sut->check_product( $product );
+
+ $this->assertSame( $product->get_id(), $result['product_id'] );
+ $this->assertArrayHasKey( 'missing_brand', $result['checks'] );
+ $this->assertArrayNotHasKey( 'google', $result['checks']['missing_brand'], 'The per-product map, not the check\'s default one' );
+ $this->assertArrayNotHasKey( 'missing_product_text', $result['checks'] );
+ $this->assertSame( 'not_listable', $result['statuses']['chatgpt'], 'No image keeps it off every channel' );
+ $this->assertSame( array_keys( $result['checks'] ), $this->sut->get_failing_check_ids( $product ) );
+ }
+
+ /**
+ * @testdox A failing check that applies to none of the product's channels is not reported, in the scan or for one product.
+ */
+ public function test_check_with_no_channel_for_the_product_is_left_out(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ $product->update_meta_data( '_wc_gla_brand', 'Acme' );
+ $product->save();
+ add_filter(
+ 'woocommerce_agent_readiness_rules',
+ static function ( $rules ) {
+ $rules['missing_brand'] = array_intersect_key( $rules['missing_brand'], array( 'google' => true ) );
+ return $rules;
+ }
+ );
+
+ $checks = array_column( $this->sut->get_report()['checks'], null, 'id' );
+ $result = $this->sut->check_product( $product );
+
+ $this->assertSame( 0, $checks['missing_brand']['count'], 'The Google brand covers the only channel left' );
+ $this->assertArrayNotHasKey( 'missing_brand', $result['checks'] );
+ $this->assertNotContains( 'missing_brand', $this->sut->get_failing_check_ids( $product ) );
+ }
+
+ /**
+ * @testdox check_product judges a variation by its parent and returns null for products the scan leaves out.
+ */
+ public function test_check_product_coverage(): void {
+ $variable = WC_Helper_Product::create_variation_product();
+ $variation = wc_get_product( $variable->get_children()[0] );
+
+ $this->assertSame( $variable->get_id(), $this->sut->check_product( $variation )['product_id'] );
+ $orphan = new \WC_Product_Variation();
+ $orphan->set_parent_id( $variable->get_id() + 1000 );
+ $this->assertNull( $this->sut->check_product( $orphan ), 'A variation without a parent is left out' );
+ $this->assertNull( $this->sut->check_product( WC_Helper_Product::create_grouped_product() ) );
+ $this->assertNull( $this->sut->check_product( WC_Helper_Product::create_simple_product( true, array( 'status' => 'draft' ) ) ) );
+ $this->assertNull( $this->sut->check_product( WC_Helper_Product::create_simple_product( true, array( 'catalog_visibility' => 'hidden' ) ) ) );
+ }
+
+ /**
+ * @testdox A report rereads shared GTINs, so check_product sees a duplicate fixed since.
+ */
+ public function test_check_product_after_a_report_sees_gtin_changes(): void {
+ $first = WC_Helper_Product::create_simple_product();
+ $first->set_global_unique_id( '4006381333931' );
+ $first->save();
+ // The product setter rejects a duplicate, as imports and direct writes do not.
+ $second_id = WC_Helper_Product::create_simple_product()->get_id();
+ update_post_meta( $second_id, '_global_unique_id', '4006381333931' );
+ $this->assertArrayHasKey( 'duplicate_gtin', $this->sut->check_product( $first )['checks'] );
+
+ delete_post_meta( $second_id, '_global_unique_id' );
+ $this->sut->get_report();
+
+ $this->assertArrayNotHasKey( 'duplicate_gtin', $this->sut->check_product( $first )['checks'] );
+ }
+
+ /**
+ * @testdox Products hidden from the shop catalog and unsupported types are counted, not checked.
+ */
+ public function test_scan_counts_hidden_and_unsupported_products(): void {
+ WC_Helper_Product::create_simple_product();
+ $hidden_variable = WC_Helper_Product::create_variation_product();
+ $hidden_variable->set_catalog_visibility( 'hidden' );
+ $hidden_variable->save();
+ $search_only = WC_Helper_Product::create_simple_product();
+ $search_only->set_catalog_visibility( 'search' );
+ $search_only->save();
+ $shop_only = WC_Helper_Product::create_simple_product();
+ $shop_only->set_catalog_visibility( 'catalog' );
+ $shop_only->save();
+ WC_Helper_Product::create_external_product();
+ // A subscription whose plugin is inactive loads as simple but keeps its stored type.
+ $subscription = WC_Helper_Product::create_simple_product();
+ wp_set_object_terms( $subscription->get_id(), 'subscription', 'product_type' );
+ \WC_Cache_Helper::invalidate_cache_group( 'product_' . $subscription->get_id() );
+ $subscription = wc_get_product( $subscription->get_id() );
+ WC_Helper_Product::create_simple_product( true, array( 'status' => 'draft' ) );
+
+ $report = $this->sut->get_report();
+
+ $this->assertSame( 2, $report['products'], 'Only the shown simple product and the shop-only one are checked' );
+ $this->assertSame( 2, $report['hidden_from_catalog'], 'Hidden and search-only products are counted apart' );
+ $this->assertSame( 2, $report['unsupported_types'], 'External and the subscription are counted apart' );
+ $this->assertSame( 2, $report['channels']['chatgpt']['listable'] + $report['channels']['chatgpt']['not_listable'] );
+ $this->assertNotContains( 'hidden_from_catalog', array_column( $report['checks'], 'id' ) );
+ $this->assertSame( array(), $this->sut->get_failing_check_ids( $hidden_variable ), 'A hidden product has no issues' );
+ $this->assertSame( array(), $this->sut->get_failing_check_ids( wc_get_product( $hidden_variable->get_children()[0] ) ), 'Nor do its variations' );
+ $this->assertSame( array(), $this->sut->get_failing_check_ids( $subscription ), 'An unsupported type has no issues' );
+ }
+
+ /**
+ * @testdox The rules filter runs once per instance, not once per product.
+ */
+ public function test_rules_are_built_once(): void {
+ $calls = 0;
+ add_filter(
+ 'woocommerce_agent_readiness_rules',
+ static function ( $rules ) use ( &$calls ) {
+ ++$calls;
+ return $rules;
+ }
+ );
+
+ $this->sut->get_failing_check_ids( WC_Helper_Product::create_simple_product() );
+ $this->sut->get_failing_check_ids( WC_Helper_Product::create_simple_product() );
+
+ $this->assertSame( 1, $calls );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/ReadinessChecksTest.php b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/ReadinessChecksTest.php
new file mode 100644
index 00000000000..cee2ab466aa
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/ReadinessChecksTest.php
@@ -0,0 +1,364 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\AgentReadiness;
+
+use Automattic\WooCommerce\Internal\AgentReadiness\Checks\DuplicateGtinCheck;
+use Automattic\WooCommerce\Internal\AgentReadiness\Checks\ImageTooSmallCheck;
+use Automattic\WooCommerce\Internal\AgentReadiness\Checks\MissingBrandCheck;
+use Automattic\WooCommerce\Internal\AgentReadiness\Checks\MissingGtinCheck;
+use Automattic\WooCommerce\Internal\AgentReadiness\Checks\MissingRegularPriceCheck;
+use Automattic\WooCommerce\Internal\AgentReadiness\Checks\SimpleCheck;
+use Automattic\WooCommerce\Internal\AgentReadiness\FeedChannels;
+use Automattic\WooCommerce\Internal\AgentReadiness\ProductFields;
+use Automattic\WooCommerce\Internal\AgentReadiness\ProductScan;
+use Automattic\WooCommerce\Internal\AgentReadiness\ReadinessRule;
+use Automattic\WooCommerce\Internal\AgentReadiness\StoreConditions;
+use Automattic\WooCommerce\Internal\ComingSoon\ComingSoonHelper;
+use WC_Helper_Product;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the agent readiness checks.
+ */
+class ReadinessChecksTest extends WC_Unit_Test_Case {
+
+ /**
+ * Shared scan for the checks.
+ *
+ * @var ProductScan
+ */
+ private ProductScan $scan;
+
+ /**
+ * Set up test fixtures.
+ */
+ public function setUp(): void {
+ parent::setUp();
+ $this->scan = new ProductScan();
+ }
+
+ /**
+ * @testdox A physical product with no brand fails; a brand term or a virtual product passes.
+ */
+ public function test_missing_brand(): void {
+ $sut = new MissingBrandCheck();
+ $product = WC_Helper_Product::create_simple_product();
+
+ $this->assertTrue( $sut->fails( $product, $this->scan ), 'A product with no brand should fail' );
+
+ wp_set_object_terms( $product->get_id(), 'Acme', 'product_brand' );
+ $this->assertFalse( $sut->fails( wc_get_product( $product->get_id() ), $this->scan ), 'A product with a brand term should pass' );
+
+ $virtual = WC_Helper_Product::create_simple_product( true, array( 'virtual' => true ) );
+ $this->assertFalse( $sut->fails( $virtual, $this->scan ), 'A virtual product is not asked for a brand' );
+ }
+
+ /**
+ * @testdox The simple checks cover every channel and judge categories and variations.
+ */
+ public function test_simple_checks(): void {
+ $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" );
+ }
+
+ $simple = WC_Helper_Product::create_simple_product();
+ $this->assertTrue( $table['uncategorised']->fails( $simple, $this->scan ), 'Only the default category' );
+ $category = wp_insert_term( 'Mugs', 'product_cat' );
+ $simple->set_category_ids( array( $category['term_id'] ) );
+ $this->assertFalse( $table['uncategorised']->fails( $simple, $this->scan ) );
+ $this->assertFalse( $table['missing_variation_images']->fails( $simple, $this->scan ), 'A simple product has no variations' );
+ $this->assertFalse( $table['variation_attributes_not_set']->fails( $simple, $this->scan ) );
+
+ $variable = WC_Helper_Product::create_variation_product();
+ $variation = wc_get_product( $variable->get_children()[0] );
+ $this->assertTrue( $table['missing_variation_images']->fails( $variable, $this->scan ), 'Variations without their own image' );
+ $attributes = $variation->get_attributes();
+ $variation->set_attributes( array_fill_keys( array_keys( $attributes ), '' ) );
+ $variation->save();
+ $this->assertTrue( $table['variation_attributes_not_set']->fails( wc_get_product( $variable->get_id() ), $this->scan ), 'A variation set to "any"' );
+ }
+
+ /**
+ * @testdox A brand set only in Google for WooCommerce still fails, but no longer counts against Google.
+ */
+ public function test_missing_brand_with_google_feed_brand(): void {
+ $sut = new MissingBrandCheck();
+ $product = WC_Helper_Product::create_simple_product();
+ $product->update_meta_data( '_wc_gla_brand', 'Acme' );
+ $product->save();
+
+ $this->assertTrue( $sut->fails( $product, $this->scan ), 'ChatGPT and Copilot do not read the Google for WooCommerce brand' );
+ $channels = $sut->channels_for( $product, $sut->channels() );
+ $this->assertArrayNotHasKey( FeedChannels::GOOGLE, $channels );
+ $this->assertArrayHasKey( FeedChannels::CHATGPT, $channels );
+ }
+
+ /**
+ * @testdox A variable product fails when any published variation lacks its own GTIN; the parent needs none, and virtual or disabled variations are skipped.
+ */
+ public function test_missing_gtin_per_variation(): void {
+ $sut = new MissingGtinCheck();
+ $variable = WC_Helper_Product::create_variation_product();
+ $children = $variable->get_children();
+ $this->assertGreaterThan( 1, count( $children ) );
+ $this->assertTrue( $sut->fails( wc_get_product( $variable->get_id() ), $this->scan ), 'No variation has a GTIN' );
+
+ foreach ( $children as $i => $child_id ) {
+ update_post_meta( $child_id, '_global_unique_id', (string) ( 4006381333000 + $i ) );
+ }
+ $this->assertFalse( $sut->fails( wc_get_product( $variable->get_id() ), $this->scan ), 'Every variation has its own GTIN; the parent has none' );
+
+ $last = wc_get_product( end( $children ) );
+ delete_post_meta( $last->get_id(), '_global_unique_id' );
+ $this->assertTrue( $sut->fails( wc_get_product( $variable->get_id() ), $this->scan ), 'One variation has no GTIN of its own, even though the others do' );
+
+ $last = wc_get_product( $last->get_id() );
+ $last->set_virtual( true );
+ $last->save();
+ $this->assertFalse( $sut->fails( wc_get_product( $variable->get_id() ), $this->scan ), 'A virtual variation is not asked for a GTIN' );
+
+ $disabled = WC_Helper_Product::create_variation_product();
+ foreach ( $disabled->get_children() as $child_id ) {
+ wp_update_post(
+ array(
+ 'ID' => $child_id,
+ 'post_status' => 'private',
+ )
+ );
+ }
+ $this->assertFalse( $sut->fails( wc_get_product( $disabled->get_id() ), $this->scan ), 'With every variation disabled, the parent is not asked for a GTIN' );
+ }
+
+ /**
+ * @testdox A product with no GTIN but a brand and an MPN no longer counts against Google.
+ */
+ public function test_missing_gtin_with_brand_and_mpn(): void {
+ $sut = new MissingGtinCheck();
+ $product = WC_Helper_Product::create_simple_product();
+ $this->assertArrayHasKey( FeedChannels::GOOGLE, $sut->channels_for( $product, $sut->channels() ), 'No brand and no MPN' );
+
+ $product->update_meta_data( '_wc_gla_mpn', 'AC-100' );
+ $product->save();
+ $this->assertArrayHasKey( FeedChannels::GOOGLE, $sut->channels_for( $product, $sut->channels() ), 'An MPN without a brand is not enough' );
+
+ $product->update_meta_data( '_wc_gla_brand', 'Acme' );
+ $product->save();
+ $this->assertTrue( $sut->fails( $product, $this->scan ) );
+ $channels = $sut->channels_for( $product, $sut->channels() );
+ $this->assertArrayNotHasKey( FeedChannels::GOOGLE, $channels );
+ $this->assertArrayHasKey( FeedChannels::COPILOT, $channels );
+
+ $branded = WC_Helper_Product::create_simple_product();
+ wp_set_object_terms( $branded->get_id(), 'Acme', 'product_brand' );
+ $branded = wc_get_product( $branded->get_id() );
+ $branded->update_meta_data( '_wc_gla_mpn', 'AC-200' );
+ $branded->save();
+ $this->assertArrayNotHasKey( FeedChannels::GOOGLE, $sut->channels_for( $branded, $sut->channels() ), 'A WooCommerce Brands brand counts too' );
+ }
+
+ /**
+ * @testdox A variable product with some unpriced variations fails as a gap; with none priced it can't be listed.
+ */
+ public function test_missing_price_on_partly_priced_variable_product(): void {
+ $sut = new MissingRegularPriceCheck();
+ $product = WC_Helper_Product::create_variation_product();
+ $children = $product->get_children();
+
+ $this->assertFalse( $sut->fails( wc_get_product( $product->get_id() ), $this->scan ), 'A fully priced variable product should pass' );
+
+ $this->unprice( (int) $children[0] );
+ $partly = wc_get_product( $product->get_id() );
+
+ $this->assertTrue( $sut->fails( $partly, $this->scan ), 'A partly priced variable product should fail' );
+ $effects = array_unique( array_column( $sut->channels_for( $partly, $sut->channels() ), 'effect' ) );
+ $this->assertSame( array( ReadinessRule::EFFECT_GAPS ), array_values( $effects ), 'Priced variations still list, so the effect is a gap' );
+
+ foreach ( $children as $child_id ) {
+ $this->unprice( (int) $child_id );
+ }
+ $unpriced = wc_get_product( $product->get_id() );
+
+ $this->assertTrue( $sut->fails( $unpriced, $this->scan ), 'A variable product with no priced variation should fail' );
+ $effects = array_unique( array_column( $sut->channels_for( $unpriced, $sut->channels() ), 'effect' ) );
+ $this->assertSame( array( ReadinessRule::EFFECT_NOT_LISTABLE ), array_values( $effects ), 'With no price at all the product can\'t be listed' );
+ }
+
+ /**
+ * @testdox GTIN validation checks length and check digit and keeps leading zeros.
+ *
+ * @testWith ["036000291452", true]
+ * ["0036000291452", true]
+ * ["00036000291452", true]
+ * ["036000291453", false]
+ * ["96385074", true]
+ * ["4006381333931", true]
+ * ["4006381333932", false]
+ * ["0306406152", false]
+ * ["12345", false]
+ * ["ABC000291452", false]
+ *
+ * @param string $gtin GTIN as stored.
+ * @param bool $expected Whether it is valid.
+ */
+ public function test_is_valid_gtin( string $gtin, bool $expected ): void {
+ $this->assertSame( $expected, ProductFields::is_valid_gtin( ProductFields::normalise_gtin( $gtin ) ) );
+ }
+
+ /**
+ * @testdox A GTIN with hyphens and spaces is normalised before validation.
+ */
+ public function test_invalid_gtin_check_normalises_separators(): void {
+ $sut = SimpleCheck::table()['invalid_gtin'];
+ $valid = WC_Helper_Product::create_simple_product( true, array( 'global_unique_id' => '4006-3813 33931' ) );
+ $invalid = WC_Helper_Product::create_simple_product( true, array( 'global_unique_id' => '4006381333932' ) );
+
+ $this->assertFalse( $sut->fails( $valid, $this->scan ), 'A valid GTIN with separators should pass' );
+ $this->assertTrue( $sut->fails( $invalid, $this->scan ), 'A GTIN with a wrong check digit should fail' );
+ }
+
+ /**
+ * @testdox GTINs that differ only by a narrow no-break space or a byte order mark count as duplicates.
+ */
+ public function test_duplicate_gtin_strips_the_same_separators_as_validation(): void {
+ $sut = new DuplicateGtinCheck();
+ $plain = WC_Helper_Product::create_simple_product();
+ $narrow = WC_Helper_Product::create_simple_product();
+ $bom = WC_Helper_Product::create_simple_product();
+ $other = WC_Helper_Product::create_simple_product();
+ // Written to meta directly, as imports do, past core's uniqueness check.
+ update_post_meta( $plain->get_id(), '_global_unique_id', '4006381333931' );
+ update_post_meta( $narrow->get_id(), '_global_unique_id', "4006381\u{202F}333931" );
+ update_post_meta( $bom->get_id(), '_global_unique_id', "\u{FEFF}036000291452" );
+ update_post_meta( $other->get_id(), '_global_unique_id', '036000291452' );
+
+ foreach ( array( $plain, $narrow, $bom, $other ) as $product ) {
+ $this->assertTrue( $sut->fails( wc_get_product( $product->get_id() ), $this->scan ), 'Product ' . $product->get_id() . ' shares its GTIN' );
+ }
+ }
+
+ /**
+ * @testdox A small main image is a gap on Google before January 31, 2027 and required from then.
+ *
+ * @testWith ["2027-01-30", "gaps"]
+ * ["2027-01-31", "not_listable"]
+ *
+ * @param string $date Date to judge at.
+ * @param string $expected Expected Google effect.
+ */
+ public function test_image_too_small_effect_switches_on_date( string $date, string $expected ): void {
+ $sut = new ImageTooSmallCheck( (int) strtotime( $date . ' 12:00:00 UTC' ) );
+
+ $this->assertSame( $expected, $sut->channels()[ FeedChannels::GOOGLE ]['effect'] );
+ }
+
+ /**
+ * @testdox An image under 500 pixels on either side fails; one without recorded size does not.
+ */
+ public function test_image_too_small_reads_attachment_size(): void {
+ $sut = new ImageTooSmallCheck();
+ $product = WC_Helper_Product::create_simple_product();
+ $attachment = self::factory()->attachment->create( array( 'post_mime_type' => 'image/jpeg' ) );
+ $product->set_image_id( $attachment );
+ $product->save();
+
+ $this->assertFalse( $sut->fails( $product, $this->scan ), 'An image with no recorded size should not be flagged' );
+
+ wp_update_attachment_metadata(
+ $attachment,
+ array(
+ 'width' => 800,
+ 'height' => 400,
+ )
+ );
+ $this->assertTrue( $sut->fails( $product, $this->scan ), 'An 800 x 400 image should fail' );
+
+ wp_update_attachment_metadata(
+ $attachment,
+ array(
+ 'width' => 500,
+ 'height' => 500,
+ )
+ );
+ $this->assertFalse( $sut->fails( $product, $this->scan ), 'A 500 x 500 image should pass' );
+ }
+
+ /**
+ * @testdox Store conditions report coming soon mode, discouraged search engines, UCP, permalinks and guest checkout.
+ */
+ public function test_store_conditions(): void {
+ global $wp_rewrite;
+ $permalinks = (string) get_option( 'permalink_structure' );
+ try {
+ $sut = new StoreConditions();
+ $sut->init( new ComingSoonHelper() );
+
+ update_option( 'woocommerce_coming_soon', 'no' );
+ update_option( 'blog_public', '1' );
+ update_option( 'woocommerce_feature_ucp_enabled', 'yes' );
+ update_option( 'woocommerce_enable_guest_checkout', 'yes' );
+ $wp_rewrite->set_permalink_structure( '/%postname%/' );
+ $this->assertSame(
+ array(
+ 'launched' => true,
+ 'search_visible' => true,
+ 'ucp_on' => true,
+ 'pretty_permalinks' => true,
+ 'guest_checkout' => true,
+ ),
+ array_column( $sut->all(), 'ok', 'id' )
+ );
+
+ update_option( 'woocommerce_coming_soon', 'yes' );
+ update_option( 'woocommerce_store_pages_only', 'yes' );
+ update_option( 'blog_public', '0' );
+ update_option( 'woocommerce_feature_ucp_enabled', 'no' );
+ update_option( 'woocommerce_enable_guest_checkout', 'no' );
+ $wp_rewrite->set_permalink_structure( '' );
+ $this->assertSame(
+ array(
+ 'launched' => false,
+ 'search_visible' => false,
+ 'ucp_on' => false,
+ 'pretty_permalinks' => false,
+ 'guest_checkout' => false,
+ ),
+ array_column( $sut->all(), 'ok', 'id' )
+ );
+
+ update_option( 'woocommerce_enable_guest_checkout', 'yes' );
+ add_filter( 'woocommerce_checkout_registration_required', '__return_true' );
+ $guest_checkout = array_column( $sut->all(), 'ok', 'id' )['guest_checkout'];
+ remove_filter( 'woocommerce_checkout_registration_required', '__return_true' );
+ $this->assertFalse( $guest_checkout, 'An extension that requires registration should fail the check' );
+
+ $this->assertSame(
+ array(
+ 'launched' => 'listing',
+ 'search_visible' => 'listing',
+ 'ucp_on' => 'ucp',
+ 'pretty_permalinks' => 'ucp',
+ 'guest_checkout' => 'checkout',
+ ),
+ array_column( $sut->all(), 'blocks', 'id' )
+ );
+ } finally {
+ $wp_rewrite->set_permalink_structure( $permalinks );
+ }
+ }
+
+ /**
+ * Clear the prices on a variation.
+ *
+ * @param int $variation_id Variation id.
+ */
+ private function unprice( int $variation_id ): void {
+ $variation = wc_get_product( $variation_id );
+ $variation->set_regular_price( '' );
+ $variation->set_sale_price( '' );
+ $variation->set_price( '' );
+ $variation->save();
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/StatusTabTest.php b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/StatusTabTest.php
new file mode 100644
index 00000000000..ea7346c320e
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/StatusTabTest.php
@@ -0,0 +1,67 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\AgentReadiness;
+
+use Automattic\WooCommerce\Internal\AgentReadiness\AgentReadiness;
+use Automattic\WooCommerce\Internal\AgentReadiness\StatusTab;
+use WC_Helper_Product;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the Agent readiness status tab.
+ */
+class StatusTabTest extends WC_Unit_Test_Case {
+
+ /**
+ * @testdox The tab renders store-wide problems, channel tags and product links from the cached report.
+ */
+ public function test_render_from_cached_report(): void {
+ wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
+ $product = WC_Helper_Product::create_simple_product();
+ update_option( 'blog_public', '0' );
+ wc_get_container()->get( AgentReadiness::class )->refresh_report();
+
+ ob_start();
+ wc_get_container()->get( StatusTab::class )->render();
+ $html = (string) ob_get_clean();
+
+ $this->assertStringContainsString( 'Store is hidden from search engines', $html );
+ $this->assertStringContainsString( 'Required by ChatGPT', $html );
+ $this->assertStringContainsString( 'Harder to find on Google', $html );
+ $this->assertStringContainsString( 'post=' . $product->get_id(), $html );
+ }
+
+ /**
+ * @testdox The tab lists the products it counts but doesn't check.
+ */
+ public function test_render_not_checked_counts(): void {
+ wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
+ $hidden = WC_Helper_Product::create_simple_product();
+ $hidden->set_catalog_visibility( 'hidden' );
+ $hidden->save();
+ WC_Helper_Product::create_external_product();
+ wc_get_container()->get( AgentReadiness::class )->refresh_report();
+
+ ob_start();
+ wc_get_container()->get( StatusTab::class )->render();
+ $html = (string) ob_get_clean();
+
+ $this->assertStringContainsString( 'Hidden from your catalog: 1', $html );
+ $this->assertStringContainsString( 'Product type not supported yet: 1', $html );
+ }
+
+ /**
+ * @testdox The tab asks for a first run when no report is cached.
+ */
+ public function test_render_without_report(): void {
+ delete_transient( AgentReadiness::REPORT_TRANSIENT );
+
+ ob_start();
+ wc_get_container()->get( StatusTab::class )->render();
+ $html = (string) ob_get_clean();
+
+ $this->assertStringContainsString( 'No check has run yet.', $html );
+ $this->assertStringContainsString( 'name="wc_agent_readiness_run"', $html );
+ }
+}