Commit 861a41f9306 for woocommerce
commit 861a41f930632b4573994bdd601c8f12ac72a520
Author: AnnaMag <AnnaMag@users.noreply.github.com>
Date: Thu Nov 12 15:40:06 2026 +0100
Agent readiness: store checks that read what the store returns (#70437)
* Agent readiness: store checks that read what the store returns
Part of #70437.
* Agent readiness: mark the changelog entry as a patch
A Comment-only changelog entry must be a patch; changelogger validate rejects it as minor.
diff --git a/plugins/woocommerce/changelog/add-agent-readiness-store-output-checks b/plugins/woocommerce/changelog/add-agent-readiness-store-output-checks
new file mode 100644
index 00000000000..ceaa026619e
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-agent-readiness-store-output-checks
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+Comment: Agent readiness: load the store's own pages for store checks, behind an experimental flag; nothing ships to merchants.
+
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/AgentReadiness.php b/plugins/woocommerce/src/Internal/AgentReadiness/AgentReadiness.php
index b3cbf181889..633a81b8f38 100644
--- a/plugins/woocommerce/src/Internal/AgentReadiness/AgentReadiness.php
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/AgentReadiness.php
@@ -37,6 +37,13 @@ class AgentReadiness {
*/
private StoreConditions $store_conditions;
+ /**
+ * Store checks that read what the store returns.
+ *
+ * @var StoreOutputChecks
+ */
+ private StoreOutputChecks $store_output_checks;
+
/**
* Rules from the published rules file.
*
@@ -63,12 +70,14 @@ class AgentReadiness {
*
* @internal
*
- * @param StoreConditions $store_conditions Store-wide conditions.
- * @param RemoteRules $remote_rules Rules from the published rules file.
+ * @param StoreConditions $store_conditions Store-wide conditions.
+ * @param StoreOutputChecks $store_output_checks Store checks that read what the store returns.
+ * @param RemoteRules $remote_rules Rules from the published rules file.
*/
- final public function init( StoreConditions $store_conditions, RemoteRules $remote_rules ): void {
- $this->store_conditions = $store_conditions;
- $this->remote_rules = $remote_rules;
+ final public function init( StoreConditions $store_conditions, StoreOutputChecks $store_output_checks, RemoteRules $remote_rules ): void {
+ $this->store_conditions = $store_conditions;
+ $this->store_output_checks = $store_output_checks;
+ $this->remote_rules = $remote_rules;
}
/**
@@ -225,7 +234,7 @@ class AgentReadiness {
'products' => $scan->products(),
'hidden_from_catalog' => $scan->hidden_from_catalog(),
'unsupported_types' => $scan->unsupported_types(),
- 'store_checks' => $this->store_conditions->all(),
+ 'store_checks' => array_merge( $this->store_conditions->all(), $this->store_output_checks->all() ),
'channels' => $scan->channel_totals(),
'checks' => $checks,
'truncated' => $scan->truncated(),
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/AgenticCommerceSettingsPage.php b/plugins/woocommerce/src/Internal/AgentReadiness/AgenticCommerceSettingsPage.php
index 8c9c8d4ac29..4864bc87380 100644
--- a/plugins/woocommerce/src/Internal/AgentReadiness/AgenticCommerceSettingsPage.php
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/AgenticCommerceSettingsPage.php
@@ -137,6 +137,7 @@ class AgenticCommerceSettingsPage extends \WC_Settings_Page {
* the store policy fields, the store address and Security.
*/
public function output(): void {
+ $report = $this->agent_readiness->get_cached_report();
$checks = $this->store_conditions->all();
$failing = $this->store_conditions->failing_required( $checks );
@@ -145,8 +146,9 @@ class AgenticCommerceSettingsPage extends \WC_Settings_Page {
echo '<div class="wc-agentic-commerce">';
echo '<p class="wc-agentic-commerce-intro">' . esc_html__( 'Let AI shopping assistants find your products, build a cart, and send shoppers to your checkout to pay. Uses the Universal Commerce Protocol (UCP).', 'woocommerce' ) . '</p>';
$this->render_switch( $checks, count( $failing ) );
- $this->render_visibility( $this->agent_readiness->get_cached_report() );
+ $this->render_visibility( $report );
$this->render_status( $checks, $this->ucp_switch->status( $failing ), count( $failing ) );
+ $this->render_readiness( self::scanned_checks( $report, $checks ), (bool) ( array_column( $checks, 'ok', 'id' )['launched'] ?? true ) );
echo '<div class="wc-agentic-commerce-card wc-agentic-commerce-policies">';
parent::output();
@@ -157,6 +159,32 @@ class AgenticCommerceSettingsPage extends \WC_Settings_Page {
echo '</div>';
}
+ /**
+ * Store checks the last scan ran that aren't read live here: the ones that
+ * read what the store returns. Never required, so they don't affect the switch.
+ *
+ * @param array<string, mixed>|null $report Stored report.
+ * @param array<int, array{id:string}> $live Store conditions read now.
+ * @return array<int, array{id:string,level:string,ok:bool|null,name:string,fix_url:string}>
+ */
+ private static function scanned_checks( ?array $report, array $live ): array {
+ $live_ids = array_column( $live, 'id' );
+ $scanned = array();
+ foreach ( is_array( $report['store_checks'] ?? null ) ? $report['store_checks'] : array() as $check ) {
+ if ( ! is_array( $check ) || ! isset( $check['id'], $check['name'] ) || in_array( $check['id'], $live_ids, true ) ) {
+ continue;
+ }
+ $scanned[] = array(
+ 'id' => (string) $check['id'],
+ 'level' => StoreConditions::LEVEL_RECOMMENDED,
+ 'ok' => isset( $check['ok'] ) ? (bool) $check['ok'] : null,
+ 'name' => (string) $check['name'],
+ 'fix_url' => (string) ( $check['fix_url'] ?? '' ),
+ );
+ }
+ return $scanned;
+ }
+
/**
* Accept only published pages from a page dropdown on this tab, or an empty
* value to clear it. A stored page that isn't published is listed in its own
@@ -386,6 +414,50 @@ class AgenticCommerceSettingsPage extends \WC_Settings_Page {
<?php
}
+ /**
+ * 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.
+ *
+ * @param array<int, array{id:string,level:string,ok:bool|null,name:string,fix_url:string}> $checks Scanned checks.
+ * @param bool $launched Whether the store is launched.
+ */
+ private function render_readiness( array $checks, bool $launched ): void {
+ ?>
+ <div class="wc-agentic-commerce-card wc-agentic-commerce-readiness">
+ <div class="wc-agentic-commerce-card__header">
+ <h2><?php esc_html_e( 'Agent readiness', 'woocommerce' ); ?></h2>
+ <p><?php esc_html_e( 'What agents get when they visit your store, from the last check.', 'woocommerce' ); ?></p>
+ </div>
+ <?php if ( array() === $checks ) : ?>
+ <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'] ); ?></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'] ) : ?>
+ <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; ?>
+ </li>
+ <?php endforeach; ?>
+ </ul>
+ <?php endif; ?>
+ </div>
+ <?php
+ }
+
/**
* What an open check asks the merchant to do: the action, the button text and where it goes.
* A policy page that is chosen but not published links to its editor; a published store page asks for another page.
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/README.md b/plugins/woocommerce/src/Internal/AgentReadiness/README.md
index 344412bdba2..393227a0deb 100644
--- a/plugins/woocommerce/src/Internal/AgentReadiness/README.md
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/README.md
@@ -4,7 +4,7 @@ Checks published simple and variable products against the product data specs of
## 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.
+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. Where a plugin can add or replace what core builds (robots.txt, sitemaps, `llms.txt`, page markup), store checks read what the store returns instead (`StoreOutputChecks`), and pass when any source provides the item. What the store can't see from inside, such as crawler blocks at a CDN, is left to an outside scan of the store's public pages.
## Channels
@@ -51,6 +51,10 @@ Only published simple and variable products are checked, the types agents suppor
Store-wide conditions (`StoreConditions`) are reported first because they affect every product: coming soon mode and "Discourage search engines" hide the products, UCP off and no HTTPS hide the catalog from UCP agents, and guest checkout off stops agents placing orders. Missing policy pages and order emails off weaken agent orders. Each condition's `blocks` says which, so a reader can tell a hidden store from one agents can't buy from.
+## Store 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. 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"). They are always `recommended`, so they never block the UCP switch.
+
## Report format
`AgentReadiness::get_report()` returns:
@@ -63,6 +67,7 @@ array(
'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).
+ // ok: true, false, or null when a store output check couldn't load the page.
array( 'id' => 'launched', 'ok' => true, 'blocks' => 'listing', 'level' => 'required', 'name' => '…', 'label' => '…', 'why' => '…', 'fix' => '…', 'fix_url' => '…' ),
),
'channels' => array(
@@ -125,6 +130,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.
- 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/StatusTab.php b/plugins/woocommerce/src/Internal/AgentReadiness/StatusTab.php
index 4e56dcbba14..f8d67d964a0 100644
--- a/plugins/woocommerce/src/Internal/AgentReadiness/StatusTab.php
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/StatusTab.php
@@ -171,12 +171,22 @@ class StatusTab implements RegisterHooksInterface {
* @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'] );
+ $failing = array();
+ $unchecked = array();
+ foreach ( $store_checks as $check ) {
+ if ( array_key_exists( 'ok', $check ) && null === $check['ok'] ) {
+ $unchecked[] = (string) $check['name'];
+ } elseif ( empty( $check['ok'] ) ) {
+ $failing[] = $check;
}
- );
+ }
+ if ( ! empty( $unchecked ) ) {
+ printf(
+ '<div class="notice notice-warning inline"><p><strong>%1$s</strong> %2$s</p></div>',
+ esc_html__( 'Your store could not load its own pages, so these were not checked:', 'woocommerce' ),
+ esc_html( implode( ', ', $unchecked ) )
+ );
+ }
if ( empty( $failing ) ) {
return;
}
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/StoreConditions.php b/plugins/woocommerce/src/Internal/AgentReadiness/StoreConditions.php
index d13554743a4..411e46b1401 100644
--- a/plugins/woocommerce/src/Internal/AgentReadiness/StoreConditions.php
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/StoreConditions.php
@@ -13,8 +13,9 @@ defined( 'ABSPATH' ) || exit;
* whatever the product data. The readiness report and the Agentic commerce
* settings page both read this list, so they never disagree.
*
- * `ok` says whether the store is clear of the problem the label names;
- * `name` is the check worded as its passing state.
+ * `ok` says whether the store is clear of the problem the label names, or is
+ * null when it couldn't be checked (StoreOutputChecks); `name` is the check
+ * worded as its passing state.
* `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 or weakens agent orders. A failing `required` condition
@@ -166,13 +167,13 @@ class StoreConditions {
/**
* Ids of the required conditions that fail.
*
- * @param array<int, array{id:string,ok:bool,level:string}>|null $conditions Conditions from all(), or null to check them now.
+ * @param array<int, array{id:string,ok:bool|null,level:string}>|null $conditions Conditions from all(), or null to check them now.
* @return array<int, string>
*/
public function failing_required( ?array $conditions = null ): array {
$ids = array();
foreach ( $conditions ?? $this->all() as $condition ) {
- if ( self::LEVEL_REQUIRED === $condition['level'] && ! $condition['ok'] ) {
+ if ( self::LEVEL_REQUIRED === $condition['level'] && false === $condition['ok'] ) {
$ids[] = $condition['id'];
}
}
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/StoreOutputChecks.php b/plugins/woocommerce/src/Internal/AgentReadiness/StoreOutputChecks.php
new file mode 100644
index 00000000000..52c07d81990
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/StoreOutputChecks.php
@@ -0,0 +1,84 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\AgentReadiness;
+
+use Automattic\WooCommerce\Internal\ComingSoon\ComingSoonHelper;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Store checks that read what the store returns (robots.txt, sitemaps, page
+ * markup), where a plugin can add or replace what core builds. They run only in
+ * the background scan, and their results are kept in the stored report in the
+ * same shape as StoreConditions.
+ *
+ * `ok` is true when any source provides the item, false when none does, and null
+ * when the store could not load its own page (couldn't check). These checks are
+ * always recommended: a result that can be "couldn't check" never blocks UCP.
+ *
+ * @since 11.4.0
+ */
+class StoreOutputChecks {
+
+ /**
+ * 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;
+ }
+
+ /**
+ * 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}>
+ */
+ public function all(): array {
+ $responses = new StoreResponses( $this->coming_soon_helper->is_site_live() );
+ $results = array();
+ foreach ( $this->checks() as $check ) {
+ try {
+ $ok = ( $check['test'] )( $responses );
+ } catch ( \Throwable $e ) {
+ // Reported as couldn't check, so one broken check doesn't stop the scan.
+ $ok = null;
+ wc_get_logger()->warning(
+ sprintf( 'Agent readiness check %s failed: %s', $check['id'], $e->getMessage() ),
+ array( 'source' => 'agent-readiness' )
+ );
+ }
+ $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'],
+ );
+ }
+ return $results;
+ }
+
+ /**
+ * 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}>
+ */
+ protected function checks(): array {
+ return array();
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/StoreResponses.php b/plugins/woocommerce/src/Internal/AgentReadiness/StoreResponses.php
new file mode 100644
index 00000000000..29cb81e5267
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/StoreResponses.php
@@ -0,0 +1,118 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\AgentReadiness;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Loads the store's own pages and files as a visitor who isn't logged in gets
+ * them, for checks that read what the store returns rather than its settings.
+ * Built once per scan; each URL is requested once and kept for the rest of it.
+ *
+ * Only URLs on the store's host are requested, and redirects are followed only
+ * on that host. A response is null when the store could not be reached or is in
+ * coming soon mode, so a check reports "couldn't check" instead of "missing".
+ *
+ * @since 11.4.0
+ */
+class StoreResponses {
+
+ public const TIMEOUT = 5;
+
+ public const MAX_BYTES = 512 * KB_IN_BYTES;
+
+ private const MAX_REDIRECTS = 2;
+
+ /**
+ * Whether visitors get the real store, not a coming soon page.
+ *
+ * @var bool
+ */
+ private bool $site_live;
+
+ /**
+ * Responses by URL.
+ *
+ * @var array<string, array{status:int,body:string}|null>
+ */
+ private array $responses = array();
+
+ /**
+ * Constructor.
+ *
+ * @param bool $site_live Whether visitors get the real store, not a coming soon page.
+ */
+ public function __construct( bool $site_live ) {
+ $this->site_live = $site_live;
+ }
+
+ /**
+ * The response for a URL on the store's host, or for a path from the home URL such as /llms.txt.
+ *
+ * @param string $url URL or path.
+ * @return array{status:int,body:string}|null Null when the store could not be reached.
+ */
+ public function get( string $url ): ?array {
+ if ( str_starts_with( $url, '/' ) ) {
+ $url = home_url( $url );
+ }
+ if ( ! array_key_exists( $url, $this->responses ) ) {
+ $this->responses[ $url ] = $this->site_live ? $this->fetch( $url ) : null;
+ }
+ return $this->responses[ $url ];
+ }
+
+ /**
+ * Request a URL, following redirects that stay on the store's host.
+ *
+ * @param string $url URL.
+ * @return array{status:int,body:string}|null
+ */
+ private function fetch( string $url ): ?array {
+ $store = self::origin( home_url() );
+ for ( $redirects = 0; $redirects <= self::MAX_REDIRECTS; $redirects++ ) {
+ if ( null === $store || self::origin( $url ) !== $store ) {
+ return null;
+ }
+ $response = wp_remote_get(
+ $url,
+ array(
+ 'timeout' => self::TIMEOUT,
+ 'redirection' => 0,
+ 'limit_response_size' => self::MAX_BYTES,
+ // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment -- Core's hook for requests a site makes to itself.
+ 'sslverify' => (bool) apply_filters( 'https_local_ssl_verify', false ),
+ )
+ );
+ if ( is_wp_error( $response ) ) {
+ return null;
+ }
+ $status = (int) wp_remote_retrieve_response_code( $response );
+ $location = wp_remote_retrieve_header( $response, 'location' );
+ if ( $status < 300 || $status >= 400 || ! is_string( $location ) || '' === $location ) {
+ return array(
+ 'status' => $status,
+ 'body' => wp_remote_retrieve_body( $response ),
+ );
+ }
+ $url = \WP_Http::make_absolute_url( $location, $url );
+ }
+ return null;
+ }
+
+ /**
+ * Host and port of an http(s) URL, or null for any other scheme. The port is
+ * only the one written in the URL, so moving from http to https on the
+ * default ports stays on the store, while another port on the same host does not.
+ *
+ * @param string $url URL.
+ */
+ private static function origin( string $url ): ?string {
+ $parts = wp_parse_url( $url );
+ if ( ! is_array( $parts ) || ! in_array( strtolower( $parts['scheme'] ?? '' ), array( 'http', 'https' ), true ) || empty( $parts['host'] ) ) {
+ return null;
+ }
+ return strtolower( $parts['host'] ) . ':' . ( $parts['port'] ?? '' );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/AgentReadinessTest.php b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/AgentReadinessTest.php
index 46bed6d6128..2ed7344ada6 100644
--- a/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/AgentReadinessTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/AgentReadinessTest.php
@@ -7,6 +7,8 @@ use Automattic\WooCommerce\Internal\AgentReadiness\AgentReadiness;
use Automattic\WooCommerce\Internal\AgentReadiness\ReadinessRule;
use Automattic\WooCommerce\Internal\AgentReadiness\RemoteRules;
use Automattic\WooCommerce\Internal\AgentReadiness\StoreConditions;
+use Automattic\WooCommerce\Internal\AgentReadiness\StoreOutputChecks;
+use Automattic\WooCommerce\Internal\AgentReadiness\StoreResponses;
use Automattic\WooCommerce\Internal\ComingSoon\ComingSoonHelper;
use WC_Helper_Product;
use WC_Unit_Test_Case;
@@ -30,8 +32,57 @@ class AgentReadinessTest extends WC_Unit_Test_Case {
parent::setUp();
$store_conditions = new StoreConditions();
$store_conditions->init( new ComingSoonHelper() );
+ $output_checks = new StoreOutputChecks();
+ $output_checks->init( new ComingSoonHelper() );
$this->sut = new AgentReadiness();
- $this->sut->init( $store_conditions, new RemoteRules() );
+ $this->sut->init( $store_conditions, $output_checks, new RemoteRules() );
+ }
+
+ /**
+ * @testdox Store output checks follow the store conditions in the report, recommended, with null for couldn't check or a check that throws.
+ */
+ public function test_report_includes_store_output_checks(): void {
+ $output_checks = new class() extends StoreOutputChecks {
+ /**
+ * Two checks: one the store couldn't load, one that throws.
+ */
+ protected function checks(): array {
+ $check = static fn( string $id, callable $test ): array => array(
+ 'id' => $id,
+ 'test' => $test,
+ 'blocks' => 'listing',
+ 'name' => $id,
+ 'label' => $id,
+ 'why' => '',
+ 'fix' => '',
+ 'fix_url' => '',
+ );
+ return array(
+ $check( 'unreachable', static fn( StoreResponses $responses ): ?bool => null === $responses->get( '/robots.txt' ) ? null : true ),
+ $check(
+ 'throws',
+ static function (): bool {
+ throw new \RuntimeException( 'Broken plugin output' );
+ }
+ ),
+ );
+ }
+ };
+ $output_checks->init( new ComingSoonHelper() );
+ $store_conditions = new StoreConditions();
+ $store_conditions->init( new ComingSoonHelper() );
+ $this->sut->init( $store_conditions, $output_checks, new RemoteRules() );
+ add_filter( 'pre_http_request', static fn() => new \WP_Error( 'http_request_failed', 'Timed out' ) );
+
+ $checks = array_column( $this->sut->get_report()['store_checks'], null, 'id' );
+
+ $this->assertSame( 'launched', array_key_first( $checks ) );
+ foreach ( array( 'unreachable', 'throws' ) as $id ) {
+ $this->assertArrayHasKey( 'ok', $checks[ $id ] );
+ $this->assertNull( $checks[ $id ]['ok'] );
+ $this->assertSame( StoreConditions::LEVEL_RECOMMENDED, $checks[ $id ]['level'] );
+ }
+ $this->assertNotContains( 'unreachable', $store_conditions->failing_required( array_values( $checks ) ) );
}
/**
diff --git a/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/AgenticCommerceSettingsTest.php b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/AgenticCommerceSettingsTest.php
index 18a1a5d5053..7accf743266 100644
--- a/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/AgenticCommerceSettingsTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/AgenticCommerceSettingsTest.php
@@ -195,6 +195,73 @@ class AgenticCommerceSettingsTest extends WC_Unit_Test_Case {
$this->assertStringContainsString( 'Review and publish', $html );
}
+ /**
+ * @testdox A store check from the last scan that couldn't run shows "Couldn't check" and doesn't block the switch.
+ */
+ public function test_output_scanned_check_couldnt_check(): void {
+ StoreConditionsTest::make_store_ready( $this );
+ update_option(
+ AgentReadiness::REPORT_OPTION,
+ array(
+ 'store_checks' => array(
+ array(
+ 'id' => 'launched',
+ 'ok' => false,
+ 'level' => 'required',
+ 'name' => 'Stale launched result',
+ 'fix_url' => '',
+ ),
+ array(
+ 'id' => 'sitemap_xml',
+ 'ok' => null,
+ 'level' => 'recommended',
+ 'name' => 'Sitemap lists your products',
+ 'fix_url' => '',
+ ),
+ ),
+ ),
+ false
+ );
+
+ $html = $this->render_page();
+
+ $this->assertStringContainsString( 'Sitemap lists your products', $html );
+ $this->assertStringContainsString( 'Couldn't check', $html );
+ $this->assertStringNotContainsString( 'Stale launched result', $html, 'Live conditions are read now, not from the report' );
+ $this->assertStringNotContainsString( 'to turn this on.', $html );
+ }
+
+ /**
+ * @testdox A check that couldn't run says why: launch the store in coming soon mode, otherwise the store couldn't load its page.
+ */
+ public function test_output_couldnt_check_message_follows_coming_soon(): void {
+ StoreConditionsTest::make_store_ready( $this );
+ update_option(
+ AgentReadiness::REPORT_OPTION,
+ array(
+ 'store_checks' => array(
+ array(
+ 'id' => 'sitemap_xml',
+ 'ok' => null,
+ 'level' => 'recommended',
+ 'name' => 'Sitemap lists your products',
+ 'fix_url' => '',
+ ),
+ ),
+ ),
+ false
+ );
+
+ $live = $this->render_page();
+ update_option( 'woocommerce_coming_soon', 'yes' );
+ $coming_soon = $this->render_page();
+
+ $this->assertStringContainsString( 'could not load its own page', $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 );
+ }
+
/**
* @testdox With UCP already on and a required check failing, the switch says the checks still need fixing.
*/
diff --git a/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/RemoteRulesTest.php b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/RemoteRulesTest.php
index a78119b28a1..d1ac48852a0 100644
--- a/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/RemoteRulesTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/RemoteRulesTest.php
@@ -6,6 +6,7 @@ namespace Automattic\WooCommerce\Tests\Internal\AgentReadiness;
use Automattic\WooCommerce\Internal\AgentReadiness\AgentReadiness;
use Automattic\WooCommerce\Internal\AgentReadiness\RemoteRules;
use Automattic\WooCommerce\Internal\AgentReadiness\StoreConditions;
+use Automattic\WooCommerce\Internal\AgentReadiness\StoreOutputChecks;
use Automattic\WooCommerce\Internal\ComingSoon\ComingSoonHelper;
use WC_Helper_Product;
use WC_Unit_Test_Case;
@@ -116,7 +117,9 @@ class RemoteRulesTest extends WC_Unit_Test_Case {
$store_conditions = new StoreConditions();
$store_conditions->init( new ComingSoonHelper() );
$agent_readiness = new AgentReadiness();
- $agent_readiness->init( $store_conditions, $this->sut );
+ $output_checks = new StoreOutputChecks();
+ $output_checks->init( new ComingSoonHelper() );
+ $agent_readiness->init( $store_conditions, $output_checks, $this->sut );
return $agent_readiness;
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/StoreConditionsTest.php b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/StoreConditionsTest.php
index e670ac531ac..c9b7c0dbb2a 100644
--- a/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/StoreConditionsTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/StoreConditionsTest.php
@@ -36,6 +36,26 @@ class StoreConditionsTest extends WC_Unit_Test_Case {
$this->assertSame( array(), $this->sut->failing_required() );
}
+ /**
+ * @testdox A required condition that couldn't be checked does not block the switch.
+ */
+ public function test_couldnt_check_is_not_failing(): void {
+ $conditions = array(
+ array(
+ 'id' => 'unknown',
+ 'ok' => null,
+ 'level' => StoreConditions::LEVEL_REQUIRED,
+ ),
+ array(
+ 'id' => 'failing',
+ 'ok' => false,
+ 'level' => StoreConditions::LEVEL_REQUIRED,
+ ),
+ );
+
+ $this->assertSame( array( 'failing' ), $this->sut->failing_required( $conditions ) );
+ }
+
/**
* @testdox Coming soon mode is required.
*/
diff --git a/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/StoreResponsesTest.php b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/StoreResponsesTest.php
new file mode 100644
index 00000000000..9b9429dcbb3
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/StoreResponsesTest.php
@@ -0,0 +1,126 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\AgentReadiness;
+
+use Automattic\WooCommerce\Internal\AgentReadiness\StoreResponses;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for loading the store's own pages.
+ */
+class StoreResponsesTest extends WC_Unit_Test_Case {
+
+ /**
+ * URLs requested in the current test.
+ *
+ * @var array<int, string>
+ */
+ private array $requested = array();
+
+ /**
+ * Answer each request from a map of URL to response, recording the URL.
+ *
+ * @param array<string, array|\WP_Error> $responses Responses by URL; anything else is a WP_Error.
+ */
+ private function serve( array $responses ): void {
+ add_filter(
+ 'pre_http_request',
+ function ( $pre, $args, $url ) use ( $responses ) {
+ unset( $pre );
+ $this->assertSame( 0, $args['redirection'], 'Redirects are followed here, not by the HTTP API' );
+ $this->requested[] = $url;
+ return $responses[ $url ] ?? new \WP_Error( 'http_request_failed', 'Timed out' );
+ },
+ 10,
+ 3
+ );
+ }
+
+ /**
+ * A response in the HTTP API's shape.
+ *
+ * @param int $status Status code.
+ * @param string $body Body.
+ * @param array<string, string> $headers Headers.
+ */
+ private static function response( int $status, string $body = '', array $headers = array() ): array {
+ return array(
+ 'headers' => new \WpOrg\Requests\Utility\CaseInsensitiveDictionary( $headers ),
+ 'body' => $body,
+ 'response' => array(
+ 'code' => $status,
+ 'message' => '',
+ ),
+ 'cookies' => array(),
+ );
+ }
+
+ /**
+ * @testdox A path is loaded from the home URL once per scan, with its status and body.
+ */
+ public function test_get_path_once(): void {
+ $this->serve( array( home_url( '/llms.txt' ) => self::response( 200, '# Store' ) ) );
+ $sut = new StoreResponses( true );
+
+ $first = $sut->get( '/llms.txt' );
+ $second = $sut->get( home_url( '/llms.txt' ) );
+
+ $this->assertSame( 200, $first['status'] );
+ $this->assertSame( '# Store', $first['body'] );
+ $this->assertSame( $first, $second );
+ $this->assertCount( 1, $this->requested );
+ }
+
+ /**
+ * @testdox A failed request is null, so the check says couldn't check; a 404 is a response.
+ */
+ public function test_failed_request_is_null(): void {
+ $this->serve( array( home_url( '/missing' ) => self::response( 404 ) ) );
+ $sut = new StoreResponses( true );
+
+ $this->assertNull( $sut->get( '/timeout' ) );
+ $this->assertSame( 404, $sut->get( '/missing' )['status'] );
+ }
+
+ /**
+ * @testdox Redirects on the store's host are followed; a redirect to another host is not requested.
+ */
+ public function test_redirects_stay_on_the_store_host(): void {
+ $this->serve(
+ array(
+ home_url( '/a' ) => self::response( 301, '', array( 'Location' => '/b' ) ),
+ home_url( '/b' ) => self::response( 200, 'B' ),
+ home_url( '/c' ) => self::response( 302, '', array( 'Location' => 'https://elsewhere.test/c' ) ),
+ )
+ );
+ $sut = new StoreResponses( true );
+
+ $this->assertSame( 'B', $sut->get( '/a' )['body'] );
+ $this->assertNull( $sut->get( '/c' ) );
+ $this->assertNull( $sut->get( 'https://elsewhere.test/' ) );
+ $this->assertNull( $sut->get( 'http://' . wp_parse_url( home_url(), PHP_URL_HOST ) . ':6379/' ), 'Another port on the store host is not the store' );
+ $this->assertNull( $sut->get( 'gopher://' . wp_parse_url( home_url(), PHP_URL_HOST ) . '/' ) );
+ $this->assertSame( array( home_url( '/a' ), home_url( '/b' ), home_url( '/c' ) ), $this->requested );
+ }
+
+ /**
+ * @testdox A redirect loop on the store's host gives up after two redirects.
+ */
+ public function test_redirect_loop_gives_up(): void {
+ $this->serve( array( home_url( '/loop' ) => self::response( 302, '', array( 'Location' => '/loop' ) ) ) );
+
+ $this->assertNull( ( new StoreResponses( true ) )->get( '/loop' ) );
+ $this->assertCount( 3, $this->requested );
+ }
+
+ /**
+ * @testdox In coming soon mode nothing is requested: visitors would get the coming soon page.
+ */
+ public function test_coming_soon_requests_nothing(): void {
+ $this->serve( array() );
+
+ $this->assertNull( ( new StoreResponses( false ) )->get( '/' ) );
+ $this->assertSame( array(), $this->requested );
+ }
+}