Commit 15b7b2d5112 for woocommerce
commit 15b7b2d511257caa7988af840f37409169267daa
Author: AnnaMag <AnnaMag@users.noreply.github.com>
Date: Fri Nov 13 21:57:32 2026 +0100
Agent readiness: group store checks, add a summary and a likely conflict state (#70457)
* Agent readiness: group store checks, add a summary and a likely conflict state
Part of #70457.
* Agent readiness: report a conflict only on a failed check, and not for missing structured data
The report, REST and the ability sent conflict as the check's static
"can conflict" flag, so a passing check on a healthy store read as a
likely conflict to an agent; only the page combined it with the result.
It is now computed once, with the result.
A product page with no structured data at all is a missing output, not
something replacing WooCommerce's, so product_jsonld_present no longer
reads as a conflict, and its fix no longer suggests turning on a
plugin's schema, which the next check would then report as replacing
WooCommerce's.
diff --git a/plugins/woocommerce/changelog/add-agent-readiness-grouped-status b/plugins/woocommerce/changelog/add-agent-readiness-grouped-status
new file mode 100644
index 00000000000..4b896aff831
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-agent-readiness-grouped-status
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Agent readiness: group store checks by the merchant's question, add a summary and a likely conflict state, behind the agent_readiness flag.
diff --git a/plugins/woocommerce/client/legacy/css/admin.scss b/plugins/woocommerce/client/legacy/css/admin.scss
index d1549b4ce67..66b9c41da41 100644
--- a/plugins/woocommerce/client/legacy/css/admin.scss
+++ b/plugins/woocommerce/client/legacy/css/admin.scss
@@ -9996,6 +9996,29 @@ body.woocommerce-settings-payments-section_legacy {
margin-block-end: 16px;
}
+.wc-agentic-commerce-summary {
+ max-inline-size: 900px;
+
+ li {
+ display: flex;
+ justify-content: space-between;
+ padding: 8px 12px;
+ margin: 0;
+ background: #fff;
+ border: 1px solid #dcdcde;
+ border-block-end-width: 0;
+
+ &:last-child {
+ border-block-end-width: 1px;
+ }
+ }
+}
+
+.wc-agentic-commerce-checks + details,
+.wc-agentic-commerce-checks ~ h4 {
+ margin-block-start: 12px;
+}
+
.wc-agentic-commerce-ucp-switch {
flex: none;
margin-block-start: 2px;
diff --git a/plugins/woocommerce/client/legacy/js/admin/wc-agentic-commerce-settings.js b/plugins/woocommerce/client/legacy/js/admin/wc-agentic-commerce-settings.js
index 69c072ad547..a98acedbe2d 100644
--- a/plugins/woocommerce/client/legacy/js/admin/wc-agentic-commerce-settings.js
+++ b/plugins/woocommerce/client/legacy/js/admin/wc-agentic-commerce-settings.js
@@ -10,6 +10,9 @@
const knob = toggle && toggle.querySelector( '.woocommerce-input-toggle' );
const message = document.querySelector( '.wc-agentic-commerce-ucp-message' );
const statusBadge = document.querySelector( '.wc-agentic-commerce-status' );
+ const accessSummary = document.querySelector(
+ '.wc-agentic-commerce-access-summary'
+ );
const render = ( enabled ) => {
toggle.setAttribute( 'aria-checked', enabled ? 'true' : 'false' );
@@ -45,6 +48,10 @@
render( response.enabled );
renderStatus( response.status );
message.textContent = response.enabled ? settings.i18n.on : '';
+ if ( accessSummary ) {
+ accessSummary.textContent =
+ accessSummary.dataset[ response.enabled ? 'on' : 'off' ];
+ }
} catch ( error ) {
render( ! enable );
message.textContent = ( error && error.message ) || settings.i18n.error;
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/AgentReadiness.php b/plugins/woocommerce/src/Internal/AgentReadiness/AgentReadiness.php
index 82780eb0a34..9246678a5a5 100644
--- a/plugins/woocommerce/src/Internal/AgentReadiness/AgentReadiness.php
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/AgentReadiness.php
@@ -253,7 +253,7 @@ class AgentReadiness {
*
* @param array<string, mixed>|null $report Stored report.
* @param array<int, array{id:string,ok:bool|null,blocks:string,level:string,name:string,label:string,why:string,fix:string,fix_url:string}>|null $live Store conditions already read, or null to read them.
- * @return array<int, array{id:string,ok:bool|null,blocks:string,level:string,name:string,label:string,why:string,fix:string,fix_url:string,pending?:bool}>
+ * @return array<int, array{id:string,ok:bool|null,blocks:string,group?:string,level:string,name:string,label:string,why:string,fix:string,fix_url:string,conflict?:bool,pending?:bool}>
*/
public function get_store_checks( ?array $report, ?array $live = null ): array {
$checks = $live ?? $this->store_conditions->all();
@@ -263,16 +263,18 @@ class AgentReadiness {
continue;
}
$checks[] = array(
- 'id' => (string) $check['id'],
- 'ok' => isset( $check['ok'] ) ? (bool) $check['ok'] : null,
- 'blocks' => (string) ( $check['blocks'] ?? '' ),
- 'level' => StoreConditions::LEVEL_RECOMMENDED,
- 'name' => (string) $check['name'],
- 'label' => (string) ( $check['label'] ?? '' ),
- 'why' => (string) ( $check['why'] ?? '' ),
- 'fix' => (string) ( $check['fix'] ?? '' ),
- 'fix_url' => (string) ( $check['fix_url'] ?? '' ),
- 'pending' => ! empty( $check['pending'] ),
+ 'id' => (string) $check['id'],
+ 'ok' => isset( $check['ok'] ) ? (bool) $check['ok'] : null,
+ 'blocks' => (string) ( $check['blocks'] ?? '' ),
+ 'group' => (string) ( $check['group'] ?? '' ),
+ 'level' => StoreConditions::LEVEL_RECOMMENDED,
+ 'name' => (string) $check['name'],
+ 'label' => (string) ( $check['label'] ?? '' ),
+ 'why' => (string) ( $check['why'] ?? '' ),
+ 'fix' => (string) ( $check['fix'] ?? '' ),
+ 'fix_url' => (string) ( $check['fix_url'] ?? '' ),
+ 'conflict' => ! empty( $check['conflict'] ),
+ 'pending' => ! empty( $check['pending'] ),
);
}
return $checks;
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/AgenticCommerceSettingsPage.php b/plugins/woocommerce/src/Internal/AgentReadiness/AgenticCommerceSettingsPage.php
index 8e437571b4d..244c912acfe 100644
--- a/plugins/woocommerce/src/Internal/AgentReadiness/AgenticCommerceSettingsPage.php
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/AgenticCommerceSettingsPage.php
@@ -25,11 +25,27 @@ class AgenticCommerceSettingsPage extends \WC_Settings_Page {
private const POLICIES_SECTION = 'agentic_commerce_policies';
+ /**
+ * Example products per product check, to keep the page short. The count column has the total.
+ */
+ private const EXAMPLES_SHOWN = 3;
+
/**
* Marks the page dropdowns on this tab, which share options with other tabs.
*/
private const PAGE_FIELD_KEY = 'agentic_commerce_page';
+ /**
+ * Checks that can only run when the check they name passed, so aren't shown when it failed.
+ */
+ private const CHECK_NEEDS = array(
+ 'product_jsonld_from_core' => 'product_jsonld_present',
+ 'product_jsonld_required_fields' => 'product_jsonld_present',
+ 'product_offer_jsonld' => 'product_jsonld_present',
+ 'llms_txt_valid' => 'llms_txt_present',
+ 'llms_txt_links_resolve' => 'llms_txt_present',
+ );
+
/**
* Store conditions.
*
@@ -399,12 +415,15 @@ class AgenticCommerceSettingsPage extends \WC_Settings_Page {
*/
private function product_links( array $product_ids ): array {
$links = array();
- foreach ( array_slice( $product_ids, 0, AgentReadiness::PRODUCT_IDS_LIMIT ) as $product_id ) {
+ foreach ( $product_ids 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>';
+ if ( self::EXAMPLES_SHOWN === count( $links ) ) {
+ break;
+ }
}
return $links;
}
@@ -534,31 +553,164 @@ class AgenticCommerceSettingsPage extends \WC_Settings_Page {
<?php
}
+ /**
+ * One line on where a question stands.
+ *
+ * @param string $group Question id.
+ * @param array<int, array{ok:bool|null,level:string,pending?:bool}> $checks The question's checks. Pending ones don't count as unchecked.
+ * @param bool $scanned Whether a scan has run.
+ * @param int $product_problems Failing product checks under the question.
+ * @param bool $ucp_scanned Whether the last scan checked UCP.
+ * @param bool $ucp_enabled Whether UCP is on.
+ */
+ private static function group_summary( string $group, array $checks, bool $scanned, int $product_problems, bool $ucp_scanned, bool $ucp_enabled ): string {
+ $fix = count( array_filter( $checks, static fn( array $check ): bool => false === $check['ok'] && StoreConditions::LEVEL_REQUIRED === $check['level'] ) );
+ $check = $product_problems + count( array_filter( $checks, static fn( array $check ): bool => false === $check['ok'] && StoreConditions::LEVEL_REQUIRED !== $check['level'] ) );
+ $unknown = count( array_filter( $checks, static fn( array $check ): bool => null === $check['ok'] && empty( $check['pending'] ) ) );
+ $parts = array();
+ if ( $fix > 0 ) {
+ /* translators: %d: number of checks. */
+ $parts[] = sprintf( _n( '%d to fix', '%d to fix', $fix, 'woocommerce' ), $fix );
+ }
+ if ( $check > 0 ) {
+ /* translators: %d: number of checks. */
+ $parts[] = sprintf( _n( '%d to check', '%d to check', $check, 'woocommerce' ), $check );
+ }
+ if ( $unknown > 0 ) {
+ /* translators: %d: number of checks. */
+ $parts[] = sprintf( _n( '%d couldn\'t be checked', '%d couldn\'t be checked', $unknown, 'woocommerce' ), $unknown );
+ }
+ if ( 'access' === $group && ! $ucp_enabled ) {
+ array_unshift( $parts, __( 'Agent shopping is off', 'woocommerce' ) );
+ }
+ if ( array() !== $parts ) {
+ return implode( ', ', $parts );
+ }
+ $ran = array_filter( $checks, static fn( array $check ): bool => empty( $check['pending'] ) );
+ if ( array() === $ran && array() !== $checks ) {
+ return __( 'Coming soon', 'woocommerce' );
+ }
+ // Rows from before UCP was turned on, or a report without store checks, don't answer the question.
+ $answered = $scanned && array() !== $ran && ( 'access' !== $group || $ucp_scanned );
+ return $answered ? __( 'Yes', 'woocommerce' ) : __( 'Run check to find out', 'woocommerce' );
+ }
+
+ /**
+ * A table of store checks.
+ *
+ * @param array<int, array{id:string,ok:bool|null,blocks:string,level:string,name:string,why:string,fix:string,fix_url:string,conflict?:bool,pending?:bool}> $checks Checks.
+ * @param bool|null $launched Whether the store is launched.
+ */
+ private function render_check_rows( array $checks, ?bool $launched ): void {
+ if ( array() === $checks ) {
+ return;
+ }
+ ?>
+ <table class="widefat striped wc-agentic-commerce-checks">
+ <tbody>
+ <?php foreach ( $checks as $check ) : ?>
+ <?php
+ $conflict = ! empty( $check['conflict'] );
+ if ( ! empty( $check['pending'] ) ) {
+ $badge = array( 'off', __( 'Coming soon', 'woocommerce' ) );
+ } elseif ( null === $check['ok'] ) {
+ $badge = array( 'check', __( 'Couldn\'t check', 'woocommerce' ) );
+ } elseif ( $check['ok'] ) {
+ $badge = array( 'pass', __( 'Pass', 'woocommerce' ) );
+ } elseif ( $conflict ) {
+ $badge = array( 'check', __( 'Likely conflict', 'woocommerce' ) );
+ } elseif ( StoreConditions::LEVEL_REQUIRED === $check['level'] ) {
+ $badge = array( 'fix', __( 'Fix', 'woocommerce' ) );
+ } else {
+ $badge = array( 'check', __( 'Check', 'woocommerce' ) );
+ }
+ ?>
+ <tr>
+ <td><mark class="wc-agentic-commerce-badge wc-agentic-commerce-badge--<?php echo esc_attr( $badge[0] ); ?>"><?php echo esc_html( $badge[1] ); ?></mark></td>
+ <td>
+ <?php echo esc_html( $check['name'] ); ?>
+ <?php if ( false === $check['ok'] ) : ?>
+ <br /><span class="description"><?php echo esc_html( trim( ( $conflict ? __( 'Something on your store may be replacing this. If you just changed it, a page cache may show the old page for a while.', 'woocommerce' ) . ' ' : '' ) . $check['why'] . ' ' . self::blocks_text( $check['blocks'] ) ) ); ?></span>
+ <?php endif; ?>
+ </td>
+ <td>
+ <?php if ( ! empty( $check['pending'] ) ) : ?>
+ <?php esc_html_e( 'Coming in a WooCommerce update.', 'woocommerce' ); ?>
+ <?php elseif ( null === $check['ok'] ) : ?>
+ <?php
+ if ( $launched ) {
+ esc_html_e( 'This couldn\'t be checked. The next check tries again.', 'woocommerce' );
+ } else {
+ esc_html_e( 'Launch your store to run this check.', 'woocommerce' );
+ }
+ ?>
+ <?php elseif ( ! $check['ok'] && '' === $check['fix_url'] ) : ?>
+ <?php echo esc_html( $check['fix'] ); ?>
+ <?php elseif ( ! $check['ok'] ) : ?>
+ <a href="<?php echo esc_url( $check['fix_url'] ); ?>"><?php echo str_starts_with( $check['fix_url'], admin_url() ) ? esc_html__( 'Open settings', 'woocommerce' ) : esc_html__( 'Learn how', 'woocommerce' ); ?></a>
+ <?php endif; ?>
+ </td>
+ </tr>
+ <?php endforeach; ?>
+ </tbody>
+ </table>
+ <?php
+ }
+
/**
* What a failing store check stops, from its `blocks` value.
*
- * @param string $blocks listing, ucp or checkout.
+ * @param string $blocks listing, discovery, ucp or checkout.
*/
private static function blocks_text( string $blocks ): string {
$texts = array(
- 'listing' => __( 'Affects every product on every channel.', 'woocommerce' ),
- 'ucp' => __( 'Affects agents that use UCP.', 'woocommerce' ),
- 'checkout' => __( 'Affects agent checkout.', 'woocommerce' ),
+ 'listing' => __( 'Affects every product on every channel.', 'woocommerce' ),
+ 'discovery' => __( 'Makes your store harder for agents to find and read.', 'woocommerce' ),
+ 'ucp' => __( 'Affects agents that use UCP.', 'woocommerce' ),
+ 'checkout' => __( 'Affects agent checkout.', 'woocommerce' ),
);
return $texts[ $blocks ] ?? '';
}
/**
- * Agent readiness: the checks the last scan ran on what the store returns. They never
- * affect the switch, so they sit apart from the setup checks in Status.
+ * Agent readiness: the checks the last scan ran on what the store returns, under the
+ * merchant question each answers, with a one-line answer per question. They never
+ * affect the switch, so they sit apart from the setup checks in Status. Run check is
+ * at the top, and the product checks sit under "Do they understand your products?".
*
- * Run check sits at the top, and the product checks from the same scan at the bottom.
- *
- * @param array<int, array{id:string,level:string,ok:bool|null,blocks:string,name:string,why:string,fix:string,fix_url:string}> $checks Scanned checks.
- * @param bool $launched Whether the store is launched.
- * @param array<string, mixed>|null $report Stored report, or null before the first scan.
+ * @param array<int, array{id:string,ok:bool|null,blocks:string,group?:string,level:string,name:string,why:string,fix:string,fix_url:string,conflict?:bool,pending?:bool}> $checks Scanned checks.
+ * @param bool $launched Whether the store is launched.
+ * @param array<string, mixed>|null $report Stored report, or null before the first scan.
*/
private function render_readiness( array $checks, bool $launched, ?array $report ): void {
+ $results = array_column( $checks, 'ok', 'id' );
+ $checks = array_filter(
+ $checks,
+ static function ( array $check ) use ( $results ): bool {
+ // With UCP off nothing is served; the switch already says so.
+ if ( ! UcpUtils::is_enabled() && 'ucp' === $check['blocks'] ) {
+ return false;
+ }
+ $needs = self::CHECK_NEEDS[ $check['id'] ] ?? null;
+ return null === $needs || false !== ( $results[ $needs ] ?? null );
+ }
+ );
+ $groups = array(
+ 'access' => array( __( 'Can agents get in?', 'woocommerce' ), array() ),
+ 'find' => array( __( 'Can they find your store?', 'woocommerce' ), array() ),
+ 'understand' => array( __( 'Do they understand your products?', 'woocommerce' ), array() ),
+ 'buy' => array( __( 'Can they buy?', 'woocommerce' ), array() ),
+ );
+ foreach ( $checks as $check ) {
+ // A report from before checks named their question has no group; the next scan fills it in.
+ if ( isset( $groups[ $check['group'] ?? '' ] ) ) {
+ $groups[ $check['group'] ][1][] = $check;
+ }
+ }
+ $product_problems = 0;
+ foreach ( is_array( $report['checks'] ?? null ) ? $report['checks'] : array() as $product_check ) {
+ $product_problems += is_array( $product_check ) && (int) ( $product_check['count'] ?? 0 ) > 0 ? 1 : 0;
+ }
?>
<div class="wc-agentic-commerce-card wc-agentic-commerce-readiness">
<div class="wc-agentic-commerce-card__header">
@@ -566,44 +718,47 @@ class AgenticCommerceSettingsPage extends \WC_Settings_Page {
<p><?php esc_html_e( 'What agents get when they visit your store, from the last check.', 'woocommerce' ); ?></p>
<?php $this->render_run( $report ); ?>
</div>
- <?php if ( array() === $checks ) : ?>
+ <?php if ( null === $report ) : ?>
<p class="wc-agentic-commerce-card__body"><?php esc_html_e( 'Results appear after the first agent readiness check.', 'woocommerce' ); ?></p>
<?php else : ?>
- <ul>
- <?php foreach ( $checks as $check ) : ?>
- <?php
- if ( null === $check['ok'] ) {
- $badge = array( 'check', __( 'Couldn\'t check', 'woocommerce' ) );
- } elseif ( $check['ok'] ) {
- $badge = array( 'pass', __( 'Pass', 'woocommerce' ) );
- } else {
- $badge = array( 'check', __( 'Check', 'woocommerce' ) );
- }
- ?>
- <li class="wc-agentic-commerce-check">
- <mark class="wc-agentic-commerce-badge wc-agentic-commerce-badge--<?php echo esc_attr( $badge[0] ); ?>"><?php echo esc_html( $badge[1] ); ?></mark>
- <span class="wc-agentic-commerce-check__text">
- <?php echo esc_html( $check['name'] ); ?>
- <?php if ( false === $check['ok'] ) : ?>
- <span class="description"><?php echo esc_html( trim( $check['why'] . ' ' . self::blocks_text( $check['blocks'] ) ) ); ?></span>
- <?php endif; ?>
- </span>
- <?php if ( null === $check['ok'] ) : ?>
- <span class="description"><?php echo $launched ? esc_html__( 'Your store could not load its own page. The next check tries again.', 'woocommerce' ) : esc_html__( 'Launch your store to run this check.', 'woocommerce' ); ?></span>
- <?php elseif ( ! $check['ok'] && '' === $check['fix_url'] ) : ?>
- <span class="description"><?php echo esc_html( $check['fix'] ); ?></span>
- <?php elseif ( ! $check['ok'] ) : ?>
- <a class="button" href="<?php echo esc_url( $check['fix_url'] ); ?>"><?php echo str_starts_with( $check['fix_url'], admin_url() ) ? esc_html__( 'Open settings', 'woocommerce' ) : esc_html__( 'Learn how', 'woocommerce' ); ?></a>
- <?php endif; ?>
+ <ul class="wc-agentic-commerce-summary">
+ <?php foreach ( $groups as $id => $group ) : ?>
+ <li>
+ <a href="#wc-agentic-commerce-group-<?php echo esc_attr( $id ); ?>"><?php echo esc_html( $group[0] ); ?></a>
+ <?php
+ $summary = static fn( bool $ucp_enabled ): string => self::group_summary( $id, $group[1], true, 'understand' === $id ? $product_problems : 0, array_key_exists( 'ucp_profile', $results ), $ucp_enabled );
+ if ( 'access' === $id ) {
+ // The switch script swaps these, so the line follows the switch without a reload.
+ printf( '<span class="wc-agentic-commerce-access-summary" data-on="%1$s" data-off="%2$s">%3$s</span>', esc_attr( $summary( true ) ), esc_attr( $summary( false ) ), esc_html( $summary( UcpUtils::is_enabled() ) ) );
+ } else {
+ echo esc_html( $summary( UcpUtils::is_enabled() ) );
+ }
+ ?>
</li>
<?php endforeach; ?>
</ul>
+ <?php foreach ( $groups as $id => $group ) : ?>
+ <div class="wc-agentic-commerce-card__section" id="wc-agentic-commerce-group-<?php echo esc_attr( $id ); ?>">
+ <h3><?php echo esc_html( $group[0] ); ?></h3>
+ <?php
+ $open = array_filter( $group[1], static fn( array $check ): bool => true !== $check['ok'] );
+ $passed = array_filter( $group[1], static fn( array $check ): bool => true === $check['ok'] );
+ $this->render_check_rows( $open, $launched );
+ if ( array() !== $passed ) {
+ /* translators: %d: number of checks that passed. */
+ echo '<details><summary>' . esc_html( sprintf( _n( '%d check passed', '%d checks passed', count( $passed ), 'woocommerce' ), count( $passed ) ) ) . '</summary>';
+ $this->render_check_rows( $passed, $launched );
+ echo '</details>';
+ }
+ ?>
+ </div>
+ <?php
+ if ( 'understand' === $id ) {
+ $this->render_product_checks( $report );
+ }
+ ?>
+ <?php endforeach; ?>
<?php endif; ?>
- <?php
- if ( null !== $report ) {
- $this->render_product_checks( $report );
- }
- ?>
</div>
<?php
}
@@ -635,6 +790,7 @@ class AgenticCommerceSettingsPage extends \WC_Settings_Page {
'search_visible' => array( __( 'Let search engines index your store', 'woocommerce' ), __( 'Open settings', 'woocommerce' ) ),
'guest_checkout' => array( __( 'Allow guest checkout', 'woocommerce' ), __( 'Open settings', 'woocommerce' ) ),
'order_emails' => array( __( 'Turn on order confirmation emails', 'woocommerce' ), __( 'Open settings', 'woocommerce' ) ),
+ 'payment_method' => array( __( 'Turn on a payment method', 'woocommerce' ), __( 'Open settings', 'woocommerce' ) ),
);
$action = $actions[ $check['id'] ] ?? array( $check['label'], __( 'Open settings', 'woocommerce' ) );
return array( $action[0], $action[1], $check['fix_url'] );
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/README.md b/plugins/woocommerce/src/Internal/AgentReadiness/README.md
index 415cda63135..5392d5ca8ff 100644
--- a/plugins/woocommerce/src/Internal/AgentReadiness/README.md
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/README.md
@@ -56,12 +56,13 @@ Store-wide conditions (`StoreConditions`) are reported first because they affect
## Store output checks
-`StoreOutputChecks` runs in the background scan only. It loads the store's own pages as a logged-out visitor (`StoreResponses`): only URLs on the store's host, redirects followed on that host only, a 5 second timeout and 512 KB per response, each URL once per scan, nothing at all in coming soon mode. A product page is read up to 2 MB, because its JSON-LD is printed in the footer and the usual cap can cut it off. Its results go into the report's `store_checks` with the same keys, with `ok` set to `null` when the store couldn't load its own page ("Couldn't check"), or when that read was cut off before any Product JSON-LD. They are always `recommended`, so they never block the UCP switch.
+`StoreOutputChecks` runs in the background scan only. It loads the store's own pages as a logged-out visitor (`StoreResponses`): only URLs on the store's host, redirects followed on that host only, a 5 second timeout and 512 KB per response, each URL once per scan, nothing at all in coming soon mode. A product page is read up to 2 MB, because its JSON-LD is printed in the footer and the usual cap can cut it off. Its results go into the report's `store_checks` with the same keys, with `ok` set to `null` when the check could not run ("Couldn't check"), or when that read was cut off before any Product JSON-LD. They are always `recommended`, so they never block the UCP switch.
| Id | Passes when |
| --- | --- |
| `sitemap_lists_products` | A sitemap listed in robots.txt, or `/wp-sitemap.xml`, `/sitemap_index.xml` or `/sitemap.xml`, names a product sitemap or product pages. An index that does not has its child sitemaps opened, at any depth, up to five child requests. |
-| `product_jsonld_present` | The newest product page has Product (or ProductGroup) JSON-LD that parses, from WooCommerce, an SEO plugin or the theme. |
+| `product_jsonld_from_core` | The product's JSON-LD is WooCommerce's own and isn't repeated; a failure shows as a likely conflict. |
+| `product_jsonld_present` | A recent product page has Product (or ProductGroup) JSON-LD that parses, from WooCommerce, an SEO plugin or the theme. |
| `product_jsonld_required_fields` | That JSON-LD gives a name, an image and an offer, from one block or several. `null` when the page has no Product JSON-LD; the settings page hides it then. |
| `product_offer_jsonld` | One of its offers has a price (`price`, `lowPrice` or a price specification, as a number or with a currency) and an availability. `null` when the page has no Product JSON-LD; the settings page hides it then. |
| `llms_txt_present` | `/llms.txt` at the host root is served as text. |
@@ -83,7 +84,7 @@ Store-wide conditions (`StoreConditions`) are reported first because they affect
A check listed in `StoreOutputChecks::PENDING` tests output WooCommerce doesn't build yet. It doesn't run: it's reported with `ok` null and `pending` true, so it reads as coming soon, not as failed or couldn't check. Remove its entry when that output ships; the issues that build the output are in the comment on the list.
-robots.txt is loaded from the host root, only to find the sitemaps it lists. Its crawler rules aren't checked: a host or CDN can rewrite the file or block crawlers without it, and the store's request to itself skips both, so they wait for an outside view of the store (WOOPRD-3953). The sitemap check is "couldn't check" when robots.txt could not be read, or a candidate sitemap could not be loaded or answered other than 200, 404 or 410, and no fetched sitemap names products. A category sitemap does not count. When an index does not name products, its child sitemaps on this host are checked, nested indexes included (Jetpack serves one past 1,000 URLs), up to five child requests in all. Sitemap lines on another host, or written as `//host/path`, are skipped and do not use up the five listed slots. Checks on the home and product page read the ones that loaded. The home page loads first; when it can't be reached, the scan makes no more requests, so a store that can't reach itself waits for one timeout. The product checks load one product page: the newest published, visible product without a password, among the five newest. With no such product, or when its page doesn't answer 200, they are "couldn't check".
+robots.txt is loaded from the host root, only to find the sitemaps it lists. Its crawler rules aren't checked: a host or CDN can rewrite the file or block crawlers without it, and the store's request to itself skips both, so they wait for an outside view of the store (WOOPRD-3953). The sitemap check is "couldn't check" when robots.txt could not be read, or a candidate sitemap could not be loaded or answered other than 200, 404 or 410, and no fetched sitemap names products. A category sitemap does not count. When an index does not name products, its child sitemaps on this host are checked, nested indexes included (Jetpack serves one past 1,000 URLs), up to five child requests in all. Sitemap lines on another host, or written as `//host/path`, are skipped and do not use up the five listed slots. Checks on the home and product page read the ones that loaded. The home page loads first; when it can't be reached, the scan makes no more requests, so a store that can't reach itself waits for one timeout. The product checks load one product page: among the five newest published simple or variable products in the shop catalog, the newest with a price and no password, or the newest without a password when none of them has a price. With no such product, or when its page doesn't answer 200, they are "couldn't check".
## Report format
@@ -96,7 +97,7 @@ array(
'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 can find but can't add to a cart yet.
'store_checks' => array(
- // blocks: 'listing' (every product on every channel), 'ucp' (the catalog for UCP agents) or 'checkout' (agent orders).
+ // blocks: 'listing' (every product on every channel), 'discovery' (harder to find and read), 'ucp' (the catalog for UCP agents) or 'checkout' (agent orders).
// ok: true, false, or null. Null means the page could not be loaded, or a product field check found no Product JSON-LD (the settings page hides that).
array( 'id' => 'launched', 'ok' => true, 'blocks' => 'listing', 'level' => 'required', 'name' => '…', 'label' => '…', 'why' => '…', 'fix' => '…', 'fix_url' => '…' ),
),
@@ -167,7 +168,7 @@ WooCommerce > Settings > Agentic commerce (`AgenticCommerceSettings`, `AgenticCo
- The UCP switch, first. It saves through `POST wc/v3/agent-readiness/ucp` (`{ "enabled": true }`). A failing required check keeps it from being turned on, and that POST answers 409; turning it off still works. In coming soon mode the card says agents can't shop the store yet and links to Site visibility.
- Product counts from the stored report (`products`, `hidden_from_catalog`, `unsupported_types`), each linking to a filtered product list.
- Status: the store conditions from `StoreConditions`, the same list the readiness report shows, in three groups. Required lists failing `required` conditions (coming soon mode and HTTPS) and puts the status at Action needed; Recommended lists failing `recommended` ones, such as a missing privacy, terms, refund and returns, or shipping policy page (policy pages are not required by the UCP spec); Done lists the passing ones by their passing state (`name`). Each open condition says what to do and links to it; a chosen policy page that isn't published links to its editor.
-- Agent readiness: the store output checks from the stored report, since they only run in the scan. They are never required, so they don't affect the switch or Status. A failing row adds why it matters and what it affects (`blocks`). The card has "Run check" with the time of the last run, and the product checks from the stored report: per-channel counts, and each failing check with its channels and up to 10 example products.
+- Agent readiness: the store output checks from the stored report, since they only run in the scan. Each check names the question it answers in `group` (`access`, `find`, `understand` or `buy`), and the card shows the checks under those four questions. They are never required, so they don't affect the switch or Status. A failing row adds why it matters and what it affects (`blocks`). The card has "Run check" with the time of the last run, and the product checks from the stored report: per-channel counts, and each failing check with its channels and up to 3 example products. The stored report still keeps up to 10 ids per check.
- Store policy pages. Refund and returns, privacy and terms are the existing options, so they stay in sync with Advanced > Page setup and Settings > Privacy; shipping policy, FAQ and warranty are new. They are for the policy links a UCP checkout can carry (`links`); the UCP endpoints don't send them yet. The dropdowns list published pages other than the cart, checkout, shop and My account pages, and saving leaves a stored page in place even when it's a draft or one of those store pages, which then fails its check.
- The store address agents start from (`/.well-known/ucp`), with Copy.
- Placeholders for parts that aren't built yet: Test your setup (WOOAI-1619) and Security (WOOAI-1620).
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/ReportAbility.php b/plugins/woocommerce/src/Internal/AgentReadiness/ReportAbility.php
index 6b6188aa945..f830864b333 100644
--- a/plugins/woocommerce/src/Internal/AgentReadiness/ReportAbility.php
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/ReportAbility.php
@@ -119,27 +119,31 @@ class ReportAbility implements AbilityDefinition {
'items' => array(
'type' => 'object',
'properties' => array(
- 'id' => array( 'type' => 'string' ),
- 'ok' => array(
+ 'id' => array( 'type' => 'string' ),
+ 'ok' => array(
'type' => array( 'boolean', 'null' ),
'description' => __( 'True when the store passes, false when it fails, null when the check could not run.', 'woocommerce' ),
),
- 'blocks' => array(
+ 'blocks' => array(
'type' => 'string',
- 'description' => __( 'What a failure stops: listing (every product everywhere), ucp (agents using UCP) or checkout (agent orders).', 'woocommerce' ),
+ 'description' => __( 'What a failure stops: listing (every product everywhere), discovery (harder for agents to find and read the store), ucp (agents using UCP) or checkout (agent orders).', 'woocommerce' ),
),
- 'level' => array(
+ 'level' => array(
'type' => 'string',
'enum' => array( StoreConditions::LEVEL_REQUIRED, StoreConditions::LEVEL_RECOMMENDED ),
),
- 'name' => array( 'type' => 'string' ),
- 'label' => array( 'type' => 'string' ),
- 'why' => array( 'type' => 'string' ),
- 'fix' => array( 'type' => 'string' ),
- 'fix_url' => array( 'type' => 'string' ),
- 'pending' => array(
+ 'name' => array( 'type' => 'string' ),
+ 'label' => array( 'type' => 'string' ),
+ 'why' => array( 'type' => 'string' ),
+ 'fix' => array( 'type' => 'string' ),
+ 'fix_url' => array( 'type' => 'string' ),
+ 'conflict' => array(
'type' => 'boolean',
- 'description' => __( 'True for a check of output a coming WooCommerce update builds; it does not run yet.', 'woocommerce' ),
+ 'description' => __( 'On output checks: true only when the check failed and the store is set to send this, so something else is likely replacing it.', 'woocommerce' ),
+ ),
+ 'pending' => array(
+ 'type' => 'boolean',
+ 'description' => __( 'On output checks: true for a check of output a coming WooCommerce update builds; it does not run yet.', 'woocommerce' ),
),
),
),
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/StoreOutputChecks.php b/plugins/woocommerce/src/Internal/AgentReadiness/StoreOutputChecks.php
index c46bc3caa81..f9920a8ef66 100644
--- a/plugins/woocommerce/src/Internal/AgentReadiness/StoreOutputChecks.php
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/StoreOutputChecks.php
@@ -44,7 +44,7 @@ class StoreOutputChecks {
/**
* Run every check. Each URL is requested at most once per call.
*
- * @return array<int, array{id:string,ok:bool|null,blocks:string,level:string,name:string,label:string,why:string,fix:string,fix_url:string,pending:bool}>
+ * @return array<int, array{id:string,ok:bool|null,blocks:string,group:string,level:string,name:string,label:string,why:string,fix:string,fix_url:string,conflict:bool,pending:bool}>
*/
public function all(): array {
$responses = new StoreResponses( $this->coming_soon_helper->is_site_live() );
@@ -64,16 +64,18 @@ class StoreOutputChecks {
);
}
$results[] = array(
- 'id' => $check['id'],
- 'ok' => $ok,
- 'blocks' => $check['blocks'],
- 'level' => StoreConditions::LEVEL_RECOMMENDED,
- 'name' => $check['name'],
- 'label' => $check['label'],
- 'why' => $check['why'],
- 'fix' => $check['fix'],
- 'fix_url' => $check['fix_url'],
- 'pending' => $pending,
+ 'id' => $check['id'],
+ 'ok' => $ok,
+ 'blocks' => $check['blocks'],
+ 'group' => $check['group'],
+ 'level' => StoreConditions::LEVEL_RECOMMENDED,
+ 'name' => $check['name'],
+ 'label' => $check['label'],
+ 'why' => $check['why'],
+ 'fix' => $check['fix'],
+ 'fix_url' => $check['fix_url'],
+ 'conflict' => false === $ok && ! empty( $check['conflict'] ),
+ 'pending' => $pending,
);
}
return $results;
@@ -111,14 +113,15 @@ class StoreOutputChecks {
/**
* The checks, each with a test that returns true, false, or null for couldn't check.
*
- * @return array<int, array{id:string,test:callable(StoreResponses):(bool|null),blocks:string,name:string,label:string,why:string,fix:string,fix_url:string}>
+ * @return array<int, array{id:string,test:callable(StoreResponses):(bool|null),blocks:string,group:string,name:string,label:string,why:string,fix:string,fix_url:string,conflict?:bool}>
*/
protected function checks(): array {
$checks = array(
array(
'id' => 'sitemap_lists_products',
+ 'group' => 'find',
'test' => fn( StoreResponses $responses ): ?bool => $this->sitemap_lists_products( $responses ),
- 'blocks' => 'listing',
+ 'blocks' => 'discovery',
'name' => __( 'A sitemap lists your products', 'woocommerce' ),
'label' => __( 'No sitemap lists your products', 'woocommerce' ),
'why' => __( 'Crawlers use the sitemap to find new and changed products.', 'woocommerce' ),
@@ -127,18 +130,32 @@ class StoreOutputChecks {
),
array(
'id' => 'product_jsonld_present',
+ 'group' => 'understand',
'test' => fn( StoreResponses $responses ): ?bool => $this->with_products( $responses, static fn( array $products ): bool => array() !== $products ),
- 'blocks' => 'listing',
- 'name' => __( 'The newest product page has product structured data', 'woocommerce' ),
- 'label' => __( 'The newest product page has no product structured data', 'woocommerce' ),
+ 'blocks' => 'discovery',
+ 'name' => __( 'A recent product page has product structured data', 'woocommerce' ),
+ 'label' => __( 'A recent product page has no product structured data', 'woocommerce' ),
'why' => __( 'Shopping crawlers and search engines read the product\'s JSON-LD to get its name, price and stock without guessing from the page.', 'woocommerce' ),
- 'fix' => __( 'WooCommerce adds it to the product page. Check whether your theme or an SEO plugin removes it, or turn on product schema in your SEO plugin.', 'woocommerce' ),
+ 'fix' => __( 'WooCommerce adds it to the product page. Check whether your theme or an SEO plugin turns WooCommerce\'s structured data off.', 'woocommerce' ),
'fix_url' => '',
),
+ array(
+ 'id' => 'product_jsonld_from_core',
+ 'group' => 'understand',
+ 'test' => fn( StoreResponses $responses ): ?bool => $this->with_products( $responses, static fn( array $products ): ?bool => array() === $products ? null : self::has_only_core_product( $products, $responses ) ),
+ 'blocks' => 'discovery',
+ 'name' => __( 'Product structured data comes from WooCommerce, once', 'woocommerce' ),
+ 'label' => __( 'Something replaces or repeats WooCommerce\'s product structured data', 'woocommerce' ),
+ 'why' => __( 'Seen on a recent product page. Agents may read values that differ from your product, or two versions of it.', 'woocommerce' ),
+ 'fix' => __( 'Usually an SEO plugin or your theme. Check its schema settings; if you chose this, you can ignore it.', 'woocommerce' ),
+ 'fix_url' => '',
+ 'conflict' => true,
+ ),
array(
'id' => 'product_jsonld_required_fields',
+ 'group' => 'understand',
'test' => fn( StoreResponses $responses ): ?bool => $this->with_products( $responses, static fn( array $products ): ?bool => array() === $products ? null : self::has_required_fields( $products ) ),
- 'blocks' => 'listing',
+ 'blocks' => 'discovery',
'name' => __( 'Product structured data has a name, image and offer', 'woocommerce' ),
'label' => __( 'Product structured data is missing a name, image or offer', 'woocommerce' ),
'why' => __( 'Without these, agents and search engines can\'t show the product as something to buy.', 'woocommerce' ),
@@ -147,8 +164,9 @@ class StoreOutputChecks {
),
array(
'id' => 'product_offer_jsonld',
+ 'group' => 'understand',
'test' => fn( StoreResponses $responses ): ?bool => $this->with_products( $responses, static fn( array $products ): ?bool => array() === $products ? null : self::has_priced_offer( $products ) ),
- 'blocks' => 'listing',
+ 'blocks' => 'discovery',
'name' => __( 'Product offer has a price and stock status', 'woocommerce' ),
'label' => __( 'Product offer is missing a price or stock status', 'woocommerce' ),
'why' => __( 'Agents compare price and availability before they suggest a product.', 'woocommerce' ),
@@ -157,8 +175,9 @@ class StoreOutputChecks {
),
array(
'id' => 'llms_txt_present',
+ 'group' => 'find',
'test' => fn( StoreResponses $responses ): ?bool => $this->with_llms_txt( $responses, '__return_true' ),
- 'blocks' => 'listing',
+ 'blocks' => 'discovery',
'name' => __( 'Store serves an llms.txt file', 'woocommerce' ),
'label' => __( 'No llms.txt file', 'woocommerce' ),
'why' => __( 'llms.txt gives AI agents a short summary of your store and links to its main pages.', 'woocommerce' ),
@@ -167,8 +186,9 @@ class StoreOutputChecks {
),
array(
'id' => 'llms_txt_valid',
+ 'group' => 'find',
'test' => fn( StoreResponses $responses ): ?bool => $this->with_llms_txt( $responses, static fn( string $llms_txt ): bool => 1 === preg_match( '/\A\s*#[ \t]+\S/', $llms_txt ) ),
- 'blocks' => 'listing',
+ 'blocks' => 'discovery',
'name' => __( 'llms.txt starts with the store\'s name as a heading', 'woocommerce' ),
'label' => __( 'llms.txt doesn\'t follow the format', 'woocommerce' ),
'why' => __( 'The format asks for a "# Name" heading first; agents use it to know whose file they read.', 'woocommerce' ),
@@ -177,8 +197,9 @@ class StoreOutputChecks {
),
array(
'id' => 'llms_txt_links_resolve',
+ 'group' => 'find',
'test' => fn( StoreResponses $responses ): ?bool => $this->llms_txt_links_resolve( $responses ),
- 'blocks' => 'listing',
+ 'blocks' => 'discovery',
'name' => __( 'Links in llms.txt open', 'woocommerce' ),
'label' => __( 'llms.txt links to pages that don\'t open', 'woocommerce' ),
'why' => __( 'Agents follow the links in llms.txt to read your store; a broken link wastes the visit.', 'woocommerce' ),
@@ -187,8 +208,9 @@ class StoreOutputChecks {
),
array(
'id' => 'open_graph_tags',
+ 'group' => 'find',
'test' => fn( StoreResponses $responses ): ?bool => $this->on_pages( $responses, static fn( string $html ): bool => self::has_open_graph( $html, 'og:title' ) && self::has_open_graph( $html, 'og:image' ) ),
- 'blocks' => 'listing',
+ 'blocks' => 'discovery',
'name' => __( 'Pages have Open Graph tags', 'woocommerce' ),
'label' => __( 'Pages are missing Open Graph tags', 'woocommerce' ),
'why' => __( 'Agents and social apps use og:title and og:image to show a page as a card with a picture.', 'woocommerce' ),
@@ -197,8 +219,9 @@ class StoreOutputChecks {
),
array(
'id' => 'meta_description',
+ 'group' => 'find',
'test' => fn( StoreResponses $responses ): ?bool => $this->on_pages( $responses, static fn( string $html ): bool => PageMarkup::has_meta( $html, 'name', 'description' ) ),
- 'blocks' => 'listing',
+ 'blocks' => 'discovery',
'name' => __( 'Pages have a meta description', 'woocommerce' ),
'label' => __( 'Pages are missing a meta description', 'woocommerce' ),
'why' => __( 'Search and AI answers often quote the meta description to say what a page is about.', 'woocommerce' ),
@@ -207,8 +230,9 @@ class StoreOutputChecks {
),
array(
'id' => 'canonical_url',
+ 'group' => 'find',
'test' => fn( StoreResponses $responses ): ?bool => $this->on_product_page( $responses, static fn( string $html ): bool => '' !== PageMarkup::canonical( $html ) ),
- 'blocks' => 'listing',
+ 'blocks' => 'discovery',
'name' => __( 'Product pages name their canonical URL', 'woocommerce' ),
'label' => __( 'Product pages don\'t name a canonical URL', 'woocommerce' ),
'why' => __( 'A product can be reached by several URLs; the canonical link tells agents which one to show.', 'woocommerce' ),
@@ -217,8 +241,9 @@ class StoreOutputChecks {
),
array(
'id' => 'organization_schema',
+ 'group' => 'find',
'test' => fn( StoreResponses $responses ): ?bool => $this->on_home_page( $responses, static fn( string $html ): bool => array() !== JsonLd::items( $html, ...self::ORGANIZATION_TYPES ) ),
- 'blocks' => 'listing',
+ 'blocks' => 'discovery',
'name' => __( 'Home page describes the store as an organization', 'woocommerce' ),
'label' => __( 'Home page has no organization structured data', 'woocommerce' ),
'why' => __( 'Organization JSON-LD gives agents your store\'s name, logo and contact details in one place.', 'woocommerce' ),
@@ -227,8 +252,9 @@ class StoreOutputChecks {
),
array(
'id' => 'ai_snippet_directives',
+ 'group' => 'find',
'test' => fn( StoreResponses $responses ): ?bool => $this->snippets_allowed( $responses ),
- 'blocks' => 'listing',
+ 'blocks' => 'discovery',
'name' => __( 'Pages let search and AI answers quote them', 'woocommerce' ),
'label' => __( 'Pages ask search and AI answers not to quote them', 'woocommerce' ),
'why' => __( 'A nosnippet or max-snippet robots rule keeps AI answers from quoting your pages, so your products get left out.', 'woocommerce' ),
@@ -237,8 +263,9 @@ class StoreOutputChecks {
),
array(
'id' => 'content_in_raw_html',
+ 'group' => 'understand',
'test' => fn( StoreResponses $responses ): ?bool => $this->product_in_raw_html( $responses ),
- 'blocks' => 'listing',
+ 'blocks' => 'discovery',
'name' => __( 'Product name and price are in the page without JavaScript', 'woocommerce' ),
'label' => __( 'Product name or price only shows with JavaScript', 'woocommerce' ),
'why' => __( 'Most AI crawlers don\'t run JavaScript, so they miss what only appears after it runs.', 'woocommerce' ),
@@ -247,6 +274,7 @@ class StoreOutputChecks {
),
array(
'id' => 'product_return_policy_schema',
+ 'group' => 'buy',
'test' => fn( StoreResponses $responses ): ?bool => $this->with_products( $responses, static fn( array $products ): bool => self::offer_has( $products, 'hasMerchantReturnPolicy' ) ),
'blocks' => 'listing',
'name' => __( 'Product structured data links your returns policy', 'woocommerce' ),
@@ -257,6 +285,7 @@ class StoreOutputChecks {
),
array(
'id' => 'product_shipping_schema',
+ 'group' => 'buy',
'test' => fn( StoreResponses $responses ): ?bool => $this->with_products( $responses, static fn( array $products ): bool => self::offer_has( $products, 'shippingDetails' ) ),
'blocks' => 'listing',
'name' => __( 'Product structured data gives shipping details', 'woocommerce' ),
@@ -267,6 +296,7 @@ class StoreOutputChecks {
),
array(
'id' => 'product_buy_action_schema',
+ 'group' => 'buy',
'test' => fn( StoreResponses $responses ): ?bool => $this->with_products( $responses, static fn( array $products ): bool => self::has_buy_action( $products ) ),
'blocks' => 'listing',
'name' => __( 'Product structured data says how to buy', 'woocommerce' ),
@@ -281,28 +311,33 @@ class StoreOutputChecks {
}
// With UCP off nothing is served, which the ucp_on store condition reports already.
$checks[] = array(
- 'id' => 'ucp_profile',
- 'test' => fn( StoreResponses $responses ): ?bool => ( $responses->get( '/.well-known/ucp' )['status'] ?? 500 ) >= 500 ? null : null !== self::ucp_endpoint( $responses ),
- 'blocks' => 'ucp',
- 'name' => __( 'Store serves its UCP profile', 'woocommerce' ),
- 'label' => __( 'UCP profile isn\'t served', 'woocommerce' ),
- 'why' => __( 'Agents read /.well-known/ucp to find your catalog. UCP is on, but visitors don\'t get the profile.', 'woocommerce' ),
- 'fix' => __( 'Save Settings > Permalinks to refresh the rules. If it still fails, ask your host whether caching or a firewall blocks /.well-known/ucp.', 'woocommerce' ),
- 'fix_url' => admin_url( 'options-permalink.php' ),
+ 'id' => 'ucp_profile',
+ 'group' => 'access',
+ 'test' => fn( StoreResponses $responses ): ?bool => ( $responses->get( '/.well-known/ucp' )['status'] ?? 500 ) >= 500 ? null : null !== self::ucp_endpoint( $responses ),
+ 'blocks' => 'ucp',
+ 'name' => __( 'Store serves its UCP profile', 'woocommerce' ),
+ 'label' => __( 'UCP profile isn\'t served', 'woocommerce' ),
+ 'why' => __( 'Agents read /.well-known/ucp to find your catalog. UCP is on, but visitors don\'t get the profile.', 'woocommerce' ),
+ 'fix' => __( 'Save Settings > Permalinks to refresh the rules. If it still fails, ask your host whether caching or a firewall blocks /.well-known/ucp.', 'woocommerce' ),
+ 'fix_url' => admin_url( 'options-permalink.php' ),
+ 'conflict' => true,
);
$checks[] = array(
- 'id' => 'ucp_profile_version',
- 'test' => fn( StoreResponses $responses ): ?bool => self::profile_version_matches( $responses ),
- 'blocks' => 'ucp',
+ 'id' => 'ucp_profile_version',
+ 'group' => 'access',
+ 'test' => fn( StoreResponses $responses ): ?bool => self::profile_version_matches( $responses ),
+ 'blocks' => 'ucp',
/* translators: %s: UCP version, a date such as 2026-04-08. */
- 'name' => sprintf( __( 'UCP profile declares version %s', 'woocommerce' ), UcpUtils::VERSION ),
- 'label' => __( 'UCP profile declares a different version', 'woocommerce' ),
- 'why' => __( 'Agents use the version to know which requests your store accepts. A different version usually means another plugin serves its own UCP profile.', 'woocommerce' ),
- 'fix' => __( 'Look for another plugin that adds UCP or agentic commerce, and turn off its UCP profile.', 'woocommerce' ),
- 'fix_url' => admin_url( 'plugins.php' ),
+ 'name' => sprintf( __( 'UCP profile declares version %s', 'woocommerce' ), UcpUtils::VERSION ),
+ 'label' => __( 'UCP profile declares a different version', 'woocommerce' ),
+ 'why' => __( 'Agents use the version to know which requests your store accepts. A different version usually means another plugin serves its own UCP profile.', 'woocommerce' ),
+ 'fix' => __( 'Look for another plugin that adds UCP or agentic commerce, and turn off its UCP profile.', 'woocommerce' ),
+ 'fix_url' => admin_url( 'plugins.php' ),
+ 'conflict' => true,
);
$checks[] = array(
'id' => 'ucp_endpoints_live',
+ 'group' => 'access',
'test' => fn( StoreResponses $responses ): ?bool => self::catalog_search_answers( $responses ),
'blocks' => 'ucp',
'name' => __( 'UCP catalog search answers', 'woocommerce' ),
@@ -313,6 +348,7 @@ class StoreOutputChecks {
);
$checks[] = array(
'id' => 'ucp_checkout',
+ 'group' => 'access',
'test' => fn( StoreResponses $responses ): ?bool => self::profile_declares( $responses, 'dev.ucp.shopping.checkout' ),
'blocks' => 'ucp',
'name' => __( 'UCP profile offers checkout', 'woocommerce' ),
@@ -489,6 +525,22 @@ class StoreOutputChecks {
return true;
}
+ /**
+ * Whether the page has one Product item and it is WooCommerce's, which it marks with the @id
+ * `<permalink>#product`. Values aren't compared: tax, currency and formatting differ without a conflict.
+ *
+ * @param array<int, array<string, mixed>> $products Product items.
+ * @param StoreResponses $responses Store responses.
+ */
+ private static function has_only_core_product( array $products, StoreResponses $responses ): ?bool {
+ $product = $responses->product();
+ if ( null === $product ) {
+ return null;
+ }
+ $schemeless = static fn( string $url ): string => (string) preg_replace( '#^https?:#i', '', html_entity_decode( $url, ENT_QUOTES | ENT_HTML5, 'UTF-8' ) );
+ return 1 === count( $products ) && $schemeless( $product->get_permalink() . '#product' ) === $schemeless( (string) ( $products[0]['@id'] ?? '' ) );
+ }
+
/**
* Whether one offer of the Product items has both a price and an availability. A price
* can be the offer's price, an AggregateOffer's lowPrice or a price specification's price.
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/StoreResponses.php b/plugins/woocommerce/src/Internal/AgentReadiness/StoreResponses.php
index bae3480ae7d..03458ced75c 100644
--- a/plugins/woocommerce/src/Internal/AgentReadiness/StoreResponses.php
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/StoreResponses.php
@@ -114,8 +114,9 @@ class StoreResponses {
/**
* The newest published product of a type the report checks that is in the shop catalog, or null when there is none.
- * Password protected products are skipped, since their page shows a form instead of the product; only the five
- * newest are looked at.
+ * Password protected products are skipped, since their page shows a form instead of the product. A product with
+ * a price comes first, so one unfinished product doesn't decide a check for the whole store; only the five newest
+ * are looked at.
*/
public function product(): ?\WC_Product {
if ( null === $this->product ) {
@@ -131,7 +132,13 @@ class StoreResponses {
)
);
foreach ( is_array( $products ) ? $products : array() as $product ) {
- if ( $product instanceof \WC_Product && '' === $product->get_post_password() ) {
+ if ( ! $product instanceof \WC_Product || '' !== $product->get_post_password() ) {
+ continue;
+ }
+ if ( false === $this->product ) {
+ $this->product = $product;
+ }
+ if ( '' !== $product->get_price() ) {
$this->product = $product;
break;
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/AgentReadinessTest.php b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/AgentReadinessTest.php
index 879e7b73111..fa6a1783a40 100644
--- a/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/AgentReadinessTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/AgentReadinessTest.php
@@ -53,6 +53,7 @@ class AgentReadinessTest extends WC_Unit_Test_Case {
'id' => $id,
'test' => $test,
'blocks' => 'listing',
+ 'group' => 'find',
'name' => $id,
'label' => $id,
'why' => '',
diff --git a/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/AgenticCommerceSettingsTest.php b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/AgenticCommerceSettingsTest.php
index e999da90bb2..83494e9acaf 100644
--- a/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/AgenticCommerceSettingsTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/AgenticCommerceSettingsTest.php
@@ -216,6 +216,7 @@ class AgenticCommerceSettingsTest extends WC_Unit_Test_Case {
),
array(
'id' => 'sitemap_xml',
+ 'group' => 'find',
'ok' => null,
'level' => 'recommended',
'name' => 'Sitemap lists your products',
@@ -245,6 +246,7 @@ class AgenticCommerceSettingsTest extends WC_Unit_Test_Case {
'store_checks' => array(
array(
'id' => 'sitemap_xml',
+ 'group' => 'find',
'ok' => null,
'level' => 'recommended',
'name' => 'Sitemap lists your products',
@@ -259,10 +261,10 @@ class AgenticCommerceSettingsTest extends WC_Unit_Test_Case {
update_option( 'woocommerce_coming_soon', 'yes' );
$coming_soon = $this->render_page();
- $this->assertStringContainsString( 'could not load its own page', $live );
+ $this->assertStringContainsString( 'This couldn't be checked.', $live );
$this->assertStringNotContainsString( 'Launch your store', $live );
$this->assertStringContainsString( 'Launch your store to run this check.', $coming_soon );
- $this->assertStringNotContainsString( 'could not load its own page', $coming_soon );
+ $this->assertStringNotContainsString( 'This couldn't be checked.', $coming_soon );
}
/**
@@ -284,6 +286,91 @@ class AgenticCommerceSettingsTest extends WC_Unit_Test_Case {
$this->assertStringNotContainsString( 'AI agents can now find and shop your store.</p>', $html );
}
+ /**
+ * @testdox The page answers each merchant question, marks a likely conflict, hides checks whose prerequisite failed, and folds passed checks away.
+ */
+ public function test_output_groups_summary_and_conflict(): void {
+ $row = static fn( string $id, ?bool $ok, bool $conflict = false ): array => array(
+ 'id' => $id,
+ 'ok' => $ok,
+ 'blocks' => 'discovery',
+ 'group' => str_starts_with( $id, 'product_' ) ? 'understand' : 'find',
+ 'name' => $id . ' name',
+ 'label' => $id . ' label',
+ 'why' => $id . ' why',
+ 'fix' => $id . ' fix',
+ 'fix_url' => '',
+ 'conflict' => $conflict,
+ );
+ update_option(
+ 'woocommerce_agent_readiness_report',
+ array(
+ 'generated_at' => gmdate( 'c' ),
+ 'store_checks' => array(
+ $row( 'product_jsonld_present', false, true ),
+ $row( 'product_offer_jsonld', null ),
+ $row( 'sitemap_lists_products', true ),
+ ),
+ )
+ );
+
+ $html = $this->render_page();
+
+ foreach ( array( 'Can agents get in?', 'Can they find your store?', 'Do they understand your products?', 'Can they buy?' ) as $question ) {
+ $this->assertStringContainsString( $question, $html );
+ }
+ $this->assertStringContainsString( 'Agent shopping is off', $html, 'UCP is off in this test' );
+ $this->assertMatchesRegularExpression( '/data-on="(?![^"]*Agent shopping is off)[^"]*" data-off="Agent shopping is off/', $html, 'The switch script can show the line for UCP on without a reload' );
+ $this->assertStringContainsString( 'Likely conflict', $html );
+ $this->assertStringContainsString( 'Something on your store may be replacing this.', $html );
+ $this->assertStringNotContainsString( 'product_offer_jsonld name', $html, 'Its prerequisite failed' );
+ $this->assertMatchesRegularExpression( '#<details>.*sitemap_lists_products name.*</details>#s', $html );
+ }
+
+ /**
+ * @testdox Every question reads the scan, so without one each asks for it; pending checks don't count as unchecked.
+ */
+ public function test_groups_need_a_scan(): void {
+ $summary = new \ReflectionMethod( AgenticCommerceSettingsPage::class, 'group_summary' );
+ $summary->setAccessible( true );
+ $passed = array(
+ array(
+ 'ok' => true,
+ 'level' => 'recommended',
+ ),
+ );
+
+ $pending = array(
+ array(
+ 'ok' => null,
+ 'level' => 'recommended',
+ 'pending' => true,
+ ),
+ );
+
+ $this->assertSame( 'Run check to find out', $summary->invoke( null, 'buy', $passed, false, 0, false, true ) );
+ $this->assertSame( 'Yes', $summary->invoke( null, 'buy', $passed, true, 0, false, true ) );
+ $this->assertSame( 'Yes', $summary->invoke( null, 'buy', array_merge( $passed, $pending ), true, 0, false, true ) );
+ $this->assertSame( 'Coming soon', $summary->invoke( null, 'buy', $pending, true, 0, false, true ), 'Only pending checks answer nothing yet' );
+ $this->assertSame( 'Run check to find out', $summary->invoke( null, 'access', $passed, true, 0, false, true ), 'A scan from before UCP was on does not answer access' );
+ $this->assertSame( 'Yes', $summary->invoke( null, 'access', $passed, true, 0, true, true ) );
+ }
+
+ /**
+ * @testdox Examples skip deleted products, so a deleted product doesn't leave the cell empty.
+ */
+ public function test_examples_skip_deleted_products(): void {
+ $kept = \WC_Helper_Product::create_simple_product();
+ $links = new \ReflectionMethod( AgenticCommerceSettingsPage::class, 'product_links' );
+ $links->setAccessible( true );
+
+ wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
+ $result = $links->invoke( $this->page(), array( 999991, 999992, 999993, $kept->get_id() ) );
+
+ $this->assertCount( 1, $result );
+ $this->assertStringContainsString( (string) $kept->get_id(), $result[0] );
+ }
+
/**
* @testdox The page shows each failing product check with its channel tags and example products, and a failing setup check says what to do.
*/
diff --git a/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/ServedOutputChecksTest.php b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/ServedOutputChecksTest.php
index 0cbf6f79b0d..63f1a61432e 100644
--- a/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/ServedOutputChecksTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/ServedOutputChecksTest.php
@@ -348,4 +348,17 @@ class ServedOutputChecksTest extends WC_Unit_Test_Case {
$this->assertTrue( $buy_action->invoke( null, array( array( 'potentialAction' => array( array( '@type' => array( 'BuyAction' ) ) ) ) ) ) );
$this->assertFalse( $buy_action->invoke( null, array( array( 'potentialAction' => array( '@type' => 'ViewAction' ) ) ) ) );
}
+
+ /**
+ * @testdox Every store output check, UCP ones included, names the question it answers on the settings page.
+ */
+ public function test_every_check_has_a_group(): void {
+ update_option( 'woocommerce_feature_ucp_enabled', 'yes' );
+ $this->serve( array() );
+ $checks = $this->sut->all();
+ $this->assertContains( 'ucp_profile', array_column( $checks, 'id' ) );
+ foreach ( $checks as $check ) {
+ $this->assertContains( $check['group'], array( 'access', 'find', 'understand', 'buy' ), $check['id'] );
+ }
+ }
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/StoreOutputChecksTest.php b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/StoreOutputChecksTest.php
index 633070a07b7..d418b70517a 100644
--- a/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/StoreOutputChecksTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/StoreOutputChecksTest.php
@@ -427,6 +427,23 @@ class StoreOutputChecksTest extends WC_Unit_Test_Case {
$this->assertSame( array( true, true, true ), $this->product_results() );
}
+ /**
+ * @testdox The product checks read the newest product with a price, so an unfinished newer product doesn't decide them.
+ */
+ public function test_product_checks_prefer_a_priced_product(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ $product->set_image_id( self::factory()->attachment->create_object( array( 'file' => 'image.jpg' ) ) );
+ $product->set_date_created( time() - HOUR_IN_SECONDS );
+ $product->save();
+ $product = wc_get_product( $product->get_id() );
+ $this->serve( array( self::path_of( $product ) => array( 200, self::page_with( array( self::core_product_jsonld( $product ) ) ) ) ) );
+ $unpriced = WC_Helper_Product::create_simple_product();
+ $unpriced->set_regular_price( '' );
+ $unpriced->save();
+
+ $this->assertSame( array( true, true, true ), $this->product_results() );
+ }
+
/**
* @testdox A shop-only product is sampled. A newer search-only product is not.
*/
@@ -468,6 +485,44 @@ class StoreOutputChecksTest extends WC_Unit_Test_Case {
$this->assertSame( array( null, null, null ), $this->product_results() );
}
+ /**
+ * @testdox WooCommerce's own product JSON-LD is no conflict, nor is none at all. One from another source, or a second one beside it, is, and the first failure says so.
+ */
+ public function test_product_jsonld_conflict(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ $path = self::path_of( $product );
+ $core = self::core_product_jsonld( $product );
+ $other = array(
+ '@type' => 'Product',
+ 'name' => 'Dummy Product',
+ );
+
+ $this->serve( array( $path => array( 200, self::page_with( array( $core ) ) ) ) );
+ $this->assertTrue( $this->results()['product_jsonld_from_core'] );
+ $this->assertFalse( array_column( $this->sut->all(), 'conflict', 'id' )['product_jsonld_from_core'], 'A passing check is no conflict' );
+
+ remove_all_filters( 'pre_http_request' );
+ $this->serve( array( $path => array( 200, self::page_with( array() ) ) ) );
+ $this->assertFalse( array_column( $this->sut->all(), 'conflict', 'id' )['product_jsonld_present'], 'Missing is not replaced' );
+
+ remove_all_filters( 'pre_http_request' );
+ $https = str_replace( 'http:', 'https:', $core );
+ $this->serve( array( $path => array( 200, self::page_with( array( $https ) ) ) ) );
+ $this->assertTrue( $this->results()['product_jsonld_from_core'], 'Only the scheme differs' );
+
+ remove_all_filters( 'pre_http_request' );
+ $this->serve( array( $path => array( 200, self::page_with( array( $other ) ) ) ) );
+ $this->assertFalse( $this->results()['product_jsonld_from_core'], 'Replaced' );
+
+ remove_all_filters( 'pre_http_request' );
+ $this->serve( array( $path => array( 200, self::page_with( array( $core, $other ) ) ) ) );
+ $this->assertFalse( $this->results()['product_jsonld_from_core'], 'Repeated' );
+
+ $conflicts = array_column( $this->sut->all(), 'conflict', 'id' );
+ $this->assertTrue( $conflicts['product_jsonld_from_core'] );
+ $this->assertFalse( $conflicts['sitemap_lists_products'] );
+ }
+
/**
* @testdox A product page without Product JSON-LD fails only the presence check. One without a price fails only the offer check. A price written with a currency still counts.
*/