Commit d0cec0f30d7 for woocommerce

commit d0cec0f30d78512ead43e129e5db52874143bd23
Author: AnnaMag <AnnaMag@users.noreply.github.com>
Date:   Fri Nov 13 21:57:32 2026 +0100

    Agent readiness: store checks for what the store serves to agents (#70441)

    * Agent readiness: store checks for what the store serves to agents

    Part of #70441.

    * Agent readiness: pending checks as a flag, and a blocked llms.txt as couldn't check

    A check for output WooCommerce doesn't build yet is reported with
    pending true, not with the id of the issue that builds it: the report
    goes out over REST and MCP, and the pages only ask whether it is coming
    soon. The issues move to the comment on the list.

    A 401 or 403 on llms.txt or one of its links now reads as couldn't check,
    like a timeout: a firewall or security plugin refusing the store's own
    request is not a missing file or a broken link.

    * Agent readiness: mark the changelog entry as a patch

    A Comment-only changelog entry must be a patch; changelogger validate rejects it as minor.

    * Agent readiness: use UCP Utils from its own namespace in the store output checks

    StoreApi\Utilities\UcpUtils is deprecated in favour of Ucp\Utils.

diff --git a/plugins/woocommerce/changelog/add-agent-readiness-served-output b/plugins/woocommerce/changelog/add-agent-readiness-served-output
new file mode 100644
index 00000000000..ab5f11c0366
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-agent-readiness-served-output
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+Comment: Agent readiness: llms.txt, meta tag, snippet, raw HTML and UCP 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 c803e74339f..82780eb0a34 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}>
+	 * @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}>
 	 */
 	public function get_store_checks( ?array $report, ?array $live = null ): array {
 		$checks   = $live ?? $this->store_conditions->all();
@@ -272,6 +272,7 @@ class AgentReadiness {
 				'why'     => (string) ( $check['why'] ?? '' ),
 				'fix'     => (string) ( $check['fix'] ?? '' ),
 				'fix_url' => (string) ( $check['fix_url'] ?? '' ),
+				'pending' => ! empty( $check['pending'] ),
 			);
 		}
 		return $checks;
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/PageMarkup.php b/plugins/woocommerce/src/Internal/AgentReadiness/PageMarkup.php
new file mode 100644
index 00000000000..b6351b82415
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/PageMarkup.php
@@ -0,0 +1,73 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\AgentReadiness;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Reads meta tags, links and text from a page the store served, for store checks
+ * that look for them whoever printed them: WordPress, WooCommerce, an SEO plugin or the theme.
+ *
+ * @since 11.4.0
+ */
+class PageMarkup {
+
+	/**
+	 * The content of every meta tag whose attribute (name or property) matches, in page order.
+	 *
+	 * @param string   $html    Page HTML.
+	 * @param string   $attr    Attribute to match, name or property.
+	 * @param callable $matches Test for the attribute's value, given in lowercase.
+	 * @return array<int, string>
+	 */
+	public static function meta( string $html, string $attr, callable $matches ): array {
+		$contents = array();
+		$tags     = new \WP_HTML_Tag_Processor( $html );
+		while ( $tags->next_tag( array( 'tag_name' => 'meta' ) ) ) {
+			$value = $tags->get_attribute( $attr );
+			if ( is_string( $value ) && $matches( strtolower( trim( $value ) ) ) ) {
+				$contents[] = trim( (string) $tags->get_attribute( 'content' ) );
+			}
+		}
+		return $contents;
+	}
+
+	/**
+	 * Whether a meta tag with that name or property has content.
+	 *
+	 * @param string $html Page HTML.
+	 * @param string $attr name or property.
+	 * @param string $name Value, such as description or og:title.
+	 */
+	public static function has_meta( string $html, string $attr, string $name ): bool {
+		$contents = self::meta( $html, $attr, static fn( string $value ): bool => $name === $value );
+		return array() !== array_filter( $contents, static fn( string $content ): bool => '' !== $content );
+	}
+
+	/**
+	 * The href of the page's canonical link, or ''.
+	 *
+	 * @param string $html Page HTML.
+	 */
+	public static function canonical( string $html ): string {
+		$tags = new \WP_HTML_Tag_Processor( $html );
+		while ( $tags->next_tag( array( 'tag_name' => 'link' ) ) ) {
+			$rel = strtolower( (string) $tags->get_attribute( 'rel' ) );
+			if ( in_array( 'canonical', explode( ' ', (string) preg_replace( '/\s+/', ' ', $rel ) ), true ) ) {
+				return trim( (string) $tags->get_attribute( 'href' ) );
+			}
+		}
+		return '';
+	}
+
+	/**
+	 * The page's text as a visitor without JavaScript reads it: no scripts, styles or tags, entities decoded.
+	 *
+	 * @param string $html Page HTML.
+	 */
+	public static function text( string $html ): string {
+		$html = (string) preg_replace( '/<!--.*?-->/s', '', $html );
+		return html_entity_decode( wp_strip_all_tags( $html ), ENT_QUOTES | ENT_HTML5, 'UTF-8' );
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/README.md b/plugins/woocommerce/src/Internal/AgentReadiness/README.md
index e8ad6768ae5..c8a30fabe91 100644
--- a/plugins/woocommerce/src/Internal/AgentReadiness/README.md
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/README.md
@@ -64,8 +64,26 @@ Store-wide conditions (`StoreConditions`) are reported first because they affect
 | `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_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. |
-
-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. 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".
+| `llms_txt_present` | `/llms.txt` at the host root is served as text. |
+| `llms_txt_valid` | It starts with a `# Name` heading. |
+| `llms_txt_links_resolve` | The first five links in it to the store's own pages open (no 4xx). Links to other sites, or with a query string, aren't loaded. |
+| `open_graph_tags` | The home and product page have `og:title` and `og:image`. |
+| `meta_description` | The home and product page have a meta description. |
+| `canonical_url` | The product page has a canonical link. |
+| `organization_schema` | The home page's JSON-LD has an Organization, or a type stores use for themselves such as OnlineStore. |
+| `ai_snippet_directives` | No robots meta tag or `X-Robots-Tag` header on the home or product page sets `nosnippet` or a `max-snippet` limit. |
+| `content_in_raw_html` | The product page's text, without scripts or comments, has the product's name and its price as a whole amount. |
+| `ucp_profile` | With UCP on: `/.well-known/ucp` is served and names a REST endpoint. |
+| `ucp_endpoints_live` | With UCP on: a catalog search POSTed to that endpoint, or WooCommerce's when there is no profile, answers with a product list. |
+| `ucp_profile_version` | With UCP on: the served profile declares the UCP version WooCommerce implements (`UcpUtils::VERSION`). A different version usually means another plugin serves its own profile. |
+| `ucp_checkout` | With UCP on: the served profile declares `dev.ucp.shopping.checkout`. Pending (WOOAI-1435). |
+| `product_return_policy_schema` | A product offer in the product page's JSON-LD has `hasMerchantReturnPolicy`. Pending (WOOPRD-3912). |
+| `product_shipping_schema` | A product offer has `shippingDetails`. Pending (WOOPRD-3908). |
+| `product_buy_action_schema` | The product or an offer has a `BuyAction` in `potentialAction`. Pending (WOOPRD-3918). |
+
+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".

 ## Report format

diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/ReportAbility.php b/plugins/woocommerce/src/Internal/AgentReadiness/ReportAbility.php
index 13891497d18..6b6188aa945 100644
--- a/plugins/woocommerce/src/Internal/AgentReadiness/ReportAbility.php
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/ReportAbility.php
@@ -137,6 +137,10 @@ class ReportAbility implements AbilityDefinition {
 									'why'     => array( 'type' => 'string' ),
 									'fix'     => array( 'type' => 'string' ),
 									'fix_url' => array( 'type' => 'string' ),
+									'pending' => array(
+										'type'        => 'boolean',
+										'description' => __( '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 6d0d098fa05..c46bc3caa81 100644
--- a/plugins/woocommerce/src/Internal/AgentReadiness/StoreOutputChecks.php
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/StoreOutputChecks.php
@@ -4,6 +4,7 @@ declare( strict_types = 1 );
 namespace Automattic\WooCommerce\Internal\AgentReadiness;

 use Automattic\WooCommerce\Internal\ComingSoon\ComingSoonHelper;
+use Automattic\WooCommerce\StoreApi\Utilities\Ucp\Utils as UcpUtils;

 defined( 'ABSPATH' ) || exit;

@@ -43,14 +44,17 @@ 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}>
+	 * @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}>
 	 */
 	public function all(): array {
 		$responses = new StoreResponses( $this->coming_soon_helper->is_site_live() );
-		$results   = array();
+		// The home page first: when it can't be reached, no other request is made.
+		$responses->home_page();
+		$results = array();
 		foreach ( $this->checks() as $check ) {
+			$pending = in_array( $check['id'], self::PENDING, true );
 			try {
-				$ok = ( $check['test'] )( $responses );
+				$ok = $pending ? null : ( $check['test'] )( $responses );
 			} catch ( \Throwable $e ) {
 				// Reported as couldn't check, so one broken check doesn't stop the scan.
 				$ok = null;
@@ -69,11 +73,25 @@ class StoreOutputChecks {
 				'why'     => $check['why'],
 				'fix'     => $check['fix'],
 				'fix_url' => $check['fix_url'],
+				'pending' => $pending,
 			);
 		}
 		return $results;
 	}

+	/**
+	 * Checks for output WooCommerce doesn't build yet. They don't run and are reported as
+	 * pending, not failed; remove the entry when the output ships. The output is built by
+	 * WOOPRD-3912 (returns policy), WOOPRD-3908 (shipping), WOOPRD-3918 (buy action) and
+	 * WOOAI-1435 (UCP checkout).
+	 */
+	private const PENDING = array(
+		'product_return_policy_schema',
+		'product_shipping_schema',
+		'product_buy_action_schema',
+		'ucp_checkout',
+	);
+
 	private const MAX_LISTED_SITEMAPS = 5;

 	private const MAX_CHILD_SITEMAPS = 5;
@@ -83,13 +101,20 @@ class StoreOutputChecks {
 	 */
 	private const ANSWER_STATUSES = array( 200, 404, 410 );

+	private const MAX_LLMS_TXT_LINKS = 5;
+
+	/**
+	 * Organization and the subtypes stores use for themselves.
+	 */
+	private const ORGANIZATION_TYPES = array( 'Organization', 'OnlineStore', 'OnlineBusiness', 'Corporation', 'LocalBusiness', 'Store' );
+
 	/**
 	 * 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(
+		$checks = array(
 			array(
 				'id'      => 'sitemap_lists_products',
 				'test'    => fn( StoreResponses $responses ): ?bool => $this->sitemap_lists_products( $responses ),
@@ -130,7 +155,183 @@ class StoreOutputChecks {
 				'fix'     => __( 'Set a price and stock status on the product. If they\'re set, check the product schema settings in your SEO plugin.', 'woocommerce' ),
 				'fix_url' => '',
 			),
+			array(
+				'id'      => 'llms_txt_present',
+				'test'    => fn( StoreResponses $responses ): ?bool => $this->with_llms_txt( $responses, '__return_true' ),
+				'blocks'  => 'listing',
+				'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' ),
+				'fix'     => __( 'Turn on llms.txt in your SEO plugin, or add an llms.txt file to your site\'s root folder.', 'woocommerce' ),
+				'fix_url' => '',
+			),
+			array(
+				'id'      => 'llms_txt_valid',
+				'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',
+				'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' ),
+				'fix'     => __( 'Start llms.txt with a line such as "# Your store name", in your SEO plugin or the file in your site\'s root folder.', 'woocommerce' ),
+				'fix_url' => '',
+			),
+			array(
+				'id'      => 'llms_txt_links_resolve',
+				'test'    => fn( StoreResponses $responses ): ?bool => $this->llms_txt_links_resolve( $responses ),
+				'blocks'  => 'listing',
+				'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' ),
+				'fix'     => __( 'Update or remove the broken links in llms.txt, in your SEO plugin or the file in your site\'s root folder.', 'woocommerce' ),
+				'fix_url' => '',
+			),
+			array(
+				'id'      => 'open_graph_tags',
+				'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',
+				'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' ),
+				'fix'     => __( 'Turn on Open Graph or social tags in your SEO plugin, or in Jetpack.', 'woocommerce' ),
+				'fix_url' => '',
+			),
+			array(
+				'id'      => 'meta_description',
+				'test'    => fn( StoreResponses $responses ): ?bool => $this->on_pages( $responses, static fn( string $html ): bool => PageMarkup::has_meta( $html, 'name', 'description' ) ),
+				'blocks'  => 'listing',
+				'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' ),
+				'fix'     => __( 'Add a meta description for your home page and products in your SEO plugin.', 'woocommerce' ),
+				'fix_url' => '',
+			),
+			array(
+				'id'      => 'canonical_url',
+				'test'    => fn( StoreResponses $responses ): ?bool => $this->on_product_page( $responses, static fn( string $html ): bool => '' !== PageMarkup::canonical( $html ) ),
+				'blocks'  => 'listing',
+				'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' ),
+				'fix'     => __( 'WordPress adds it to product pages. Check whether your theme or a plugin removes it.', 'woocommerce' ),
+				'fix_url' => '',
+			),
+			array(
+				'id'      => 'organization_schema',
+				'test'    => fn( StoreResponses $responses ): ?bool => $this->on_home_page( $responses, static fn( string $html ): bool => array() !== JsonLd::items( $html, ...self::ORGANIZATION_TYPES ) ),
+				'blocks'  => 'listing',
+				'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' ),
+				'fix'     => __( 'Fill in your organization or business details in your SEO plugin.', 'woocommerce' ),
+				'fix_url' => '',
+			),
+			array(
+				'id'      => 'ai_snippet_directives',
+				'test'    => fn( StoreResponses $responses ): ?bool => $this->snippets_allowed( $responses ),
+				'blocks'  => 'listing',
+				'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' ),
+				'fix'     => __( 'Remove nosnippet and max-snippet from the robots settings in your SEO plugin, or ask your host about an X-Robots-Tag header.', 'woocommerce' ),
+				'fix_url' => '',
+			),
+			array(
+				'id'      => 'content_in_raw_html',
+				'test'    => fn( StoreResponses $responses ): ?bool => $this->product_in_raw_html( $responses ),
+				'blocks'  => 'listing',
+				'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' ),
+				'fix'     => __( 'Use a theme or page builder that prints the product name and price in the page, or ask its developer.', 'woocommerce' ),
+				'fix_url' => '',
+			),
+			array(
+				'id'      => 'product_return_policy_schema',
+				'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' ),
+				'label'   => __( 'Product structured data has no returns policy', 'woocommerce' ),
+				'why'     => __( 'Agents tell shoppers whether they can return an item before they buy, and read it from the product\'s structured data.', 'woocommerce' ),
+				'fix'     => __( 'Publish your refund and returns page and choose it under Store policies.', 'woocommerce' ),
+				'fix_url' => '',
+			),
+			array(
+				'id'      => 'product_shipping_schema',
+				'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' ),
+				'label'   => __( 'Product structured data has no shipping details', 'woocommerce' ),
+				'why'     => __( 'Agents compare delivery options, and leave out products whose shipping they can\'t tell.', 'woocommerce' ),
+				'fix'     => __( 'Set up shipping zones so WooCommerce can add shipping details to your products.', 'woocommerce' ),
+				'fix_url' => admin_url( 'admin.php?page=wc-settings&tab=shipping' ),
+			),
+			array(
+				'id'      => 'product_buy_action_schema',
+				'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' ),
+				'label'   => __( 'Product structured data has no buy action', 'woocommerce' ),
+				'why'     => __( 'A BuyAction tells agents where to send a shopper to buy the product.', 'woocommerce' ),
+				'fix'     => __( 'Check whether another plugin replaces WooCommerce\'s product structured data.', 'woocommerce' ),
+				'fix_url' => '',
+			),
+		);
+		if ( ! UcpUtils::is_enabled() ) {
+			return $checks;
+		}
+		// 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' ),
+		);
+		$checks[] = array(
+			'id'      => 'ucp_profile_version',
+			'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' ),
 		);
+		$checks[] = array(
+			'id'      => 'ucp_endpoints_live',
+			'test'    => fn( StoreResponses $responses ): ?bool => self::catalog_search_answers( $responses ),
+			'blocks'  => 'ucp',
+			'name'    => __( 'UCP catalog search answers', 'woocommerce' ),
+			'label'   => __( 'UCP catalog search doesn\'t answer', 'woocommerce' ),
+			'why'     => __( 'Agents search your catalog through UCP; when it fails they can\'t find your products.', 'woocommerce' ),
+			'fix'     => __( 'Ask your host or security plugin to allow requests to the WordPress REST API under /wp-json/wc/ucp/.', 'woocommerce' ),
+			'fix_url' => '',
+		);
+		$checks[] = array(
+			'id'      => 'ucp_checkout',
+			'test'    => fn( StoreResponses $responses ): ?bool => self::profile_declares( $responses, 'dev.ucp.shopping.checkout' ),
+			'blocks'  => 'ucp',
+			'name'    => __( 'UCP profile offers checkout', 'woocommerce' ),
+			'label'   => __( 'UCP profile doesn\'t offer checkout', 'woocommerce' ),
+			'why'     => __( 'Agents build a cart and hand the shopper to your checkout through UCP checkout.', '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' ),
+		);
+		return $checks;
+	}
+
+	/**
+	 * A path at the host root, including when WordPress lives in a subdirectory.
+	 *
+	 * @param string $path Path from the host root, such as /llms.txt.
+	 */
+	private static function host_root( string $path ): string {
+		$home = (array) wp_parse_url( home_url() );
+		return ( $home['scheme'] ?? 'http' ) . '://' . ( $home['host'] ?? '' ) . ( isset( $home['port'] ) ? ':' . $home['port'] : '' ) . $path;
 	}

 	/**
@@ -142,8 +343,7 @@ class StoreOutputChecks {
 	 * @param StoreResponses $responses Store responses.
 	 */
 	private function robots_txt( StoreResponses $responses ): ?string {
-		$home     = (array) wp_parse_url( home_url() );
-		$response = $responses->get( ( $home['scheme'] ?? 'http' ) . '://' . ( $home['host'] ?? '' ) . ( isset( $home['port'] ) ? ':' . $home['port'] : '' ) . '/robots.txt' );
+		$response = $responses->get( self::host_root( '/robots.txt' ) );
 		if ( null === $response || ! in_array( $response['status'], self::ANSWER_STATUSES, true ) ) {
 			return null;
 		}
@@ -314,6 +514,279 @@ class StoreOutputChecks {
 		return false;
 	}

+	/**
+	 * Run a test on the product page's HTML, or null when it couldn't be loaded.
+	 *
+	 * @param StoreResponses $responses Store responses.
+	 * @param callable       $test      Test, given the HTML.
+	 */
+	private function on_product_page( StoreResponses $responses, callable $test ): ?bool {
+		$page = $responses->product_page();
+		return null === $page ? null : (bool) $test( $page['body'] );
+	}
+
+	/**
+	 * Run a test on the home page's HTML, or null when it couldn't be loaded.
+	 *
+	 * @param StoreResponses $responses Store responses.
+	 * @param callable       $test      Test, given the HTML.
+	 */
+	private function on_home_page( StoreResponses $responses, callable $test ): ?bool {
+		$page = $responses->home_page();
+		return null === $page ? null : (bool) $test( $page['body'] );
+	}
+
+	/**
+	 * Whether a test passes on the home page and the product page, of those that loaded.
+	 * Null when neither loaded.
+	 *
+	 * @param StoreResponses $responses Store responses.
+	 * @param callable       $test      Test, given the HTML and the response headers.
+	 */
+	private function on_pages( StoreResponses $responses, callable $test ): ?bool {
+		$pages = array_filter( array( $responses->home_page(), $responses->product_page() ) );
+		if ( array() === $pages ) {
+			return null;
+		}
+		foreach ( $pages as $page ) {
+			if ( ! $test( $page['body'], $page['headers'] ) ) {
+				return false;
+			}
+		}
+		return true;
+	}
+
+	/**
+	 * Whether an Open Graph tag has content. Open Graph uses the property attribute; some plugins write name.
+	 *
+	 * @param string $html Page HTML.
+	 * @param string $tag  Tag, such as og:title.
+	 */
+	private static function has_open_graph( string $html, string $tag ): bool {
+		return PageMarkup::has_meta( $html, 'property', $tag ) || PageMarkup::has_meta( $html, 'name', $tag );
+	}
+
+	/**
+	 * Run a test on the served llms.txt: false when there is none, null when it couldn't be loaded.
+	 *
+	 * @param StoreResponses $responses Store responses.
+	 * @param callable       $test      Test, given the file.
+	 */
+	private function with_llms_txt( StoreResponses $responses, callable $test ): ?bool {
+		$response = $responses->get( self::host_root( '/llms.txt' ) );
+		if ( null === $response || self::blocked( $response['status'] ) ) {
+			return null;
+		}
+		$body = trim( $response['body'] );
+		// A theme that answers every URL with a page has no llms.txt.
+		if ( 200 !== $response['status'] || '' === $body || str_starts_with( $body, '<' ) ) {
+			return false;
+		}
+		return (bool) $test( $body );
+	}
+
+	/**
+	 * Whether the first few links in llms.txt to the store's own pages open. Links to other sites
+	 * aren't loaded. Null when one couldn't be loaded and none was broken.
+	 *
+	 * @param StoreResponses $responses Store responses.
+	 */
+	private function llms_txt_links_resolve( StoreResponses $responses ): ?bool {
+		$links = array();
+		$has   = $this->with_llms_txt(
+			$responses,
+			static function ( string $llms_txt ) use ( &$links ): bool {
+				preg_match_all( '/\]\(\s*(https?:\/\/[^\s)]+)/i', $llms_txt, $matches );
+				$links = $matches[1];
+				return true;
+			}
+		);
+		if ( true !== $has ) {
+			return $has;
+		}
+		// Links with a query string are skipped: one such as ?add-to-cart=12 would act on the store.
+		$links   = array_filter( $links, static fn( string $link ): bool => $responses->is_store_url( $link ) && ! str_contains( $link, '?' ) );
+		$links   = array_slice( array_values( array_unique( $links ) ), 0, self::MAX_LLMS_TXT_LINKS );
+		$unknown = false;
+		foreach ( $links as $link ) {
+			$response = $responses->get( $link );
+			if ( null === $response || self::blocked( $response['status'] ) ) {
+				$unknown = true;
+			} elseif ( $response['status'] >= 400 ) {
+				return false;
+			}
+		}
+		return $unknown ? null : true;
+	}
+
+	/**
+	 * Whether a status says the store's request to itself was refused or failed, not that the
+	 * page is missing: a server error, or a firewall or security plugin answering 401 or 403.
+	 * A check then reports couldn't check, as it does when the page times out.
+	 *
+	 * @param int $status HTTP status.
+	 */
+	private static function blocked( int $status ): bool {
+		return $status >= 500 || in_array( $status, array( 401, 403 ), true );
+	}
+
+	/**
+	 * Whether no robots meta tag or X-Robots-Tag header on the home and product page sets
+	 * nosnippet, or a max-snippet other than -1 (no limit). Rules for one crawler count too.
+	 *
+	 * @param StoreResponses $responses Store responses.
+	 */
+	private function snippets_allowed( StoreResponses $responses ): ?bool {
+		return $this->on_pages(
+			$responses,
+			static function ( string $html, array $headers ): bool {
+				$rules   = PageMarkup::meta( $html, 'name', static fn( string $name ): bool => 'robots' === $name || str_ends_with( $name, 'bot' ) );
+				$rules[] = $headers['x-robots-tag'] ?? '';
+				$rules   = strtolower( implode( ', ', $rules ) );
+				if ( preg_match( '/(?<![\w-])nosnippet\b/', $rules ) ) {
+					return false;
+				}
+				preg_match_all( '/max-snippet\s*:\s*(-?\d+)/', $rules, $limits );
+				return array() === array_diff( $limits[1], array( '-1' ) );
+			}
+		);
+	}
+
+	/**
+	 * Whether the product page's text, without running JavaScript, has the product's name and, when it has one, its price.
+	 *
+	 * @param StoreResponses $responses Store responses.
+	 */
+	private function product_in_raw_html( StoreResponses $responses ): ?bool {
+		$product = $responses->product();
+		return $this->on_product_page(
+			$responses,
+			static function ( string $html ) use ( $product ): bool {
+				$text = PageMarkup::text( $html );
+				if ( null === $product || ! str_contains( $text, html_entity_decode( $product->get_name(), ENT_QUOTES | ENT_HTML5, 'UTF-8' ) ) ) {
+					return false;
+				}
+				if ( '' === $product->get_price() ) {
+					return true;
+				}
+				// The amount as the store prints it, with its separators and filters, without the currency symbol.
+				$amount = PageMarkup::text( wc_price( wc_get_price_to_display( $product ) ) );
+				$amount = trim( (string) preg_replace( '/[^\d.,\x{00A0}\x{2009}\x{202F}\' ]/u', '', $amount ) );
+				if ( '' === $amount ) {
+					return false;
+				}
+				return 1 === preg_match( '/(?<!\d)' . preg_quote( $amount, '/' ) . '(?!\d)/', $text );
+			}
+		);
+	}
+
+	/**
+	 * The UCP REST endpoint the served profile names, or null when the profile isn't served or names none.
+	 *
+	 * @param StoreResponses $responses Store responses.
+	 */
+	private static function ucp_endpoint( StoreResponses $responses ): ?string {
+		$profile = self::served_profile( $responses );
+		foreach ( (array) ( $profile['ucp']['services']['dev.ucp.shopping'] ?? array() ) as $service ) {
+			if ( is_array( $service ) && 'rest' === ( $service['transport'] ?? '' ) && is_string( $service['endpoint'] ?? null ) && '' !== $service['endpoint'] ) {
+				return $service['endpoint'];
+			}
+		}
+		return null;
+	}
+
+	/**
+	 * The served UCP profile, or null when it isn't served as a UCP profile.
+	 *
+	 * @param StoreResponses $responses Store responses.
+	 * @return array<string, mixed>|null
+	 */
+	private static function served_profile( StoreResponses $responses ): ?array {
+		$response = $responses->get( '/.well-known/ucp' );
+		$profile  = null !== $response && 200 === $response['status'] ? json_decode( $response['body'], true ) : null;
+		return is_array( $profile ) && is_array( $profile['ucp'] ?? null ) ? $profile : null;
+	}
+
+	/**
+	 * Whether the served profile declares the UCP version WooCommerce implements. Null when no
+	 * profile is served, which the ucp_profile check reports.
+	 *
+	 * @param StoreResponses $responses Store responses.
+	 */
+	private static function profile_version_matches( StoreResponses $responses ): ?bool {
+		$profile = self::served_profile( $responses );
+		return null === $profile ? null : UcpUtils::VERSION === ( $profile['ucp']['version'] ?? null );
+	}
+
+	/**
+	 * Whether the served profile declares a capability. Null when no profile is served.
+	 *
+	 * @param StoreResponses $responses  Store responses.
+	 * @param string         $capability Capability name.
+	 */
+	private static function profile_declares( StoreResponses $responses, string $capability ): ?bool {
+		$profile = self::served_profile( $responses );
+		return null === $profile ? null : ! empty( $profile['ucp']['capabilities'][ $capability ] );
+	}
+
+	/**
+	 * Whether an offer of the Product items has a property.
+	 *
+	 * @param array<int, array<string, mixed>> $products Product items.
+	 * @param string                           $property Offer property.
+	 */
+	private static function offer_has( array $products, string $property ): bool {
+		foreach ( $products as $product ) {
+			foreach ( self::as_list( $product['offers'] ?? null ) as $offer ) {
+				if ( ! empty( $offer[ $property ] ) ) {
+					return true;
+				}
+			}
+		}
+		return false;
+	}
+
+	/**
+	 * Whether a Product item or one of its offers has a BuyAction potentialAction.
+	 *
+	 * @param array<int, array<string, mixed>> $products Product items.
+	 */
+	private static function has_buy_action( array $products ): bool {
+		foreach ( $products as $product ) {
+			$items = array_merge( array( $product ), self::as_list( $product['offers'] ?? null ) );
+			foreach ( $items as $item ) {
+				foreach ( self::as_list( $item['potentialAction'] ?? null ) as $action ) {
+					if ( in_array( 'BuyAction', (array) ( $action['@type'] ?? array() ), true ) ) {
+						return true;
+					}
+				}
+			}
+		}
+		return false;
+	}
+
+	/**
+	 * Whether a catalog search through the UCP endpoint answers with a product list, using
+	 * the endpoint in the served profile, or WooCommerce's when the profile isn't served.
+	 *
+	 * @param StoreResponses $responses Store responses.
+	 */
+	private static function catalog_search_answers( StoreResponses $responses ): ?bool {
+		$endpoint = self::ucp_endpoint( $responses ) ?? rest_url( 'wc/ucp/v1' );
+		$response = $responses->post_json(
+			untrailingslashit( $endpoint ) . '/catalog/search',
+			array(
+				'filters'    => array( 'price' => array( 'min' => 0 ) ),
+				'pagination' => array( 'limit' => 1 ),
+			)
+		);
+		if ( null === $response || $response['status'] >= 500 ) {
+			return null;
+		}
+		$body = json_decode( $response['body'], true );
+		return 200 === $response['status'] && is_array( $body['products'] ?? null );
+	}
+
 	/**
 	 * Whether a JSON-LD price is a number, or text that contains one (`10.00`, `10,00`, `10.00 USD`).
 	 *
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/StoreResponses.php b/plugins/woocommerce/src/Internal/AgentReadiness/StoreResponses.php
index fc90c9da225..bae3480ae7d 100644
--- a/plugins/woocommerce/src/Internal/AgentReadiness/StoreResponses.php
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/StoreResponses.php
@@ -16,6 +16,7 @@ defined( 'ABSPATH' ) || exit;
  * 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".
+ * When the home page can't be reached, the rest of the scan makes no more requests.
  *
  * A product page is read further than other files. Product JSON-LD is printed in
  * the footer, so a cap that keeps only the start of the page drops it.
@@ -44,18 +45,25 @@ class StoreResponses {
 	private bool $site_live;

 	/**
-	 * Responses by URL.
+	 * False after the home page failed to connect or timed out.
 	 *
-	 * @var array<string, array{status:int,body:string,truncated:bool}|null>
+	 * @var bool
+	 */
+	private bool $reachable = true;
+
+	/**
+	 * Responses by request.
+	 *
+	 * @var array<string, array{status:int,body:string,headers:array<string,string>,truncated:bool}|null>
 	 */
 	private array $responses = array();

 	/**
-	 * URL of the product page checks read, '' when there is none, null until looked up.
+	 * The product whose page checks read, false when there is none, null until looked up.
 	 *
-	 * @var string|null
+	 * @var \WC_Product|false|null
 	 */
-	private ?string $product_url = null;
+	private $product = null;

 	/**
 	 * Constructor.
@@ -70,12 +78,10 @@ class StoreResponses {
 	 * 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,truncated:bool}|null Null when the store could not be reached.
+	 * @return array{status:int,body:string,headers:array<string,string>,truncated:bool}|null Null when the store could not be reached.
 	 */
 	public function get( string $url ): ?array {
-		if ( str_starts_with( $url, '/' ) ) {
-			$url = home_url( $url );
-		}
+		$url = self::absolute( $url );
 		if ( ! array_key_exists( $url, $this->responses ) ) {
 			$this->responses[ $url ] = $this->site_live ? $this->fetch( $url ) : null;
 		}
@@ -83,22 +89,42 @@ class StoreResponses {
 	}

 	/**
-	 * The page of the newest published product shoppers can find, or null when there is
-	 * none or it could not be loaded. Password protected products are skipped, since
-	 * their page shows a form instead of the product; only the five newest are looked at.
+	 * The response to a JSON POST to a URL on the store's host, such as a UCP endpoint. Redirects are not followed.
 	 *
-	 * Read further than other files. Product JSON-LD is printed in the footer, so
-	 * the usual cap would drop it.
-	 *
-	 * @return array{status:int,body:string,truncated:bool}|null
+	 * @param string               $url  URL or path.
+	 * @param array<string, mixed> $body Request body.
+	 * @return array{status:int,body:string,headers:array<string,string>,truncated:bool}|null Null when the store could not be reached.
 	 */
-	public function product_page(): ?array {
-		if ( null === $this->product_url ) {
-			$this->product_url = '';
-			$products          = wc_get_products(
+	public function post_json( string $url, array $body ): ?array {
+		$url  = self::absolute( $url );
+		$json = (string) wp_json_encode( $body );
+		$key  = 'POST ' . $url . ' ' . $json;
+		if ( ! array_key_exists( $key, $this->responses ) ) {
+			$this->responses[ $key ] = $this->site_live && $this->is_store_url( $url ) ? $this->request(
+				$url,
+				array(
+					'method'  => 'POST',
+					'headers' => array( 'Content-Type' => 'application/json' ),
+					'body'    => $json,
+				)
+			) : null;
+		}
+		return $this->responses[ $key ];
+	}
+
+	/**
+	 * 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.
+	 */
+	public function product(): ?\WC_Product {
+		if ( null === $this->product ) {
+			$this->product = false;
+			$products      = wc_get_products(
 				array(
 					'status'     => ProductStatus::PUBLISH,
-					'visibility' => CatalogVisibility::VISIBLE,
+					'visibility' => CatalogVisibility::CATALOG,
+					'type'       => ProductScan::SUPPORTED_TYPES,
 					'limit'      => 5,
 					'orderby'    => 'date',
 					'order'      => 'DESC',
@@ -106,96 +132,154 @@ class StoreResponses {
 			);
 			foreach ( is_array( $products ) ? $products : array() as $product ) {
 				if ( $product instanceof \WC_Product && '' === $product->get_post_password() ) {
-					$this->product_url = (string) $product->get_permalink();
+					$this->product = $product;
 					break;
 				}
 			}
 		}
-		if ( '' === $this->product_url ) {
+		return $this->product ? $this->product : null;
+	}
+
+	/**
+	 * The page of `product()`, or null when there is no product or its page didn't answer 200.
+	 * Read up to PRODUCT_PAGE_BYTES, so the footer JSON-LD survives the cap.
+	 *
+	 * @return array{status:int,body:string,headers:array<string,string>,truncated:bool}|null
+	 */
+	public function product_page(): ?array {
+		$product = $this->product();
+		if ( null === $product ) {
 			return null;
 		}
-		if ( ! array_key_exists( $this->product_url, $this->responses ) ) {
-			$this->responses[ $this->product_url ] = $this->site_live ? $this->fetch( $this->product_url, true ) : null;
+		$url = self::absolute( (string) $product->get_permalink() );
+		$key = 'PRODUCT ' . $url;
+		if ( ! array_key_exists( $key, $this->responses ) ) {
+			$this->responses[ $key ] = $this->site_live ? $this->fetch( $url, self::PRODUCT_PAGE_BYTES ) : null;
 		}
-		$response = $this->responses[ $this->product_url ];
-		return null !== $response && 200 === $response['status'] ? $response : null;
+		return self::ok( $this->responses[ $key ] );
 	}

 	/**
-	 * Request a URL, following redirects that stay on the store's host.
+	 * The home page, or null when it didn't answer 200.
 	 *
-	 * @param string $url          URL.
-	 * @param bool   $product_page Whether this is the product page, whose footer has to survive the cap.
-	 * @return array{status:int,body:string,truncated:bool}|null
+	 * @return array{status:int,body:string,headers:array<string,string>,truncated:bool}|null
 	 */
-	private function fetch( string $url, bool $product_page = false ): ?array {
-		$limit = $product_page ? self::PRODUCT_PAGE_BYTES : self::MAX_BYTES;
+	public function home_page(): ?array {
+		return self::ok( $this->get( '/' ) );
+	}
+
+	/**
+	 * Whether a path or URL is on the store's host, so `get()` would request it.
+	 * Paths from the home URL are. A robots.txt Sitemap line on another host is not,
+	 * nor a //host/path reference, since a sitemap location must be a full URL.
+	 *
+	 * @param string $url URL or path.
+	 */
+	public static function is_store_url( string $url ): bool {
+		if ( str_starts_with( $url, '//' ) ) {
+			return false;
+		}
 		$store = self::origin( home_url() );
+		return null !== $store && self::origin( str_starts_with( $url, '/' ) ? home_url( $url ) : $url ) === $store;
+	}
+
+	/**
+	 * Request a URL, following redirects that stay on the store's host.
+	 * A redirect to a URL with a query string is not followed.
+	 *
+	 * @param string $url   URL.
+	 * @param int    $limit Bytes to read.
+	 * @return array{status:int,body:string,headers:array<string,string>,truncated:bool}|null
+	 */
+	private function fetch( string $url, int $limit = self::MAX_BYTES ): ?array {
 		for ( $redirects = 0; $redirects <= self::MAX_REDIRECTS; $redirects++ ) {
-			if ( null === $store || self::origin( $url ) !== $store ) {
+			if ( ! $this->is_store_url( $url ) ) {
 				return null;
 			}
-			$response = wp_remote_get(
-				$url,
-				array(
-					'timeout'             => self::TIMEOUT,
-					'redirection'         => 0,
-					'limit_response_size' => $limit,
-					// 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 ) ) {
+			$response = $this->request( $url, array(), $limit );
+			if ( null === $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 $this->capped_response( $response, $status, $limit );
+			$location = $response['headers']['location'] ?? '';
+			if ( $response['status'] < 300 || $response['status'] >= 400 || '' === $location ) {
+				return $response;
 			}
 			$url = \WP_Http::make_absolute_url( $location, $url );
+			if ( is_string( wp_parse_url( $url, PHP_URL_QUERY ) ) ) {
+				return null;
+			}
 		}
 		return null;
 	}

 	/**
-	 * The response with its body held to the read limit. `truncated` means the
-	 * response stopped early, so a product page's footer may not be in the body.
+	 * One request, without following redirects. Only the headers checks read are kept.
+	 * `truncated` means the response stopped at the read limit, so the end of the body may be missing.
 	 *
-	 * @param array $response HTTP response.
-	 * @param int   $status   Status code.
-	 * @param int   $limit    Bytes the request was allowed to read.
-	 * @return array{status:int,body:string,truncated:bool}
+	 * @param string               $url   URL.
+	 * @param array<string, mixed> $args  Extra request arguments.
+	 * @param int                  $limit Bytes to read.
+	 * @return array{status:int,body:string,headers:array<string,string>,truncated:bool}|null
 	 */
-	private function capped_response( array $response, int $status, int $limit ): array {
+	private function request( string $url, array $args = array(), int $limit = self::MAX_BYTES ): ?array {
+		if ( ! $this->reachable ) {
+			return null;
+		}
+		$response = wp_remote_request(
+			$url,
+			$args + array(
+				'method'              => 'GET',
+				'timeout'             => self::TIMEOUT,
+				'redirection'         => 0,
+				'limit_response_size' => $limit,
+				// 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 ) ) {
+			// When the home page can't be reached, other URLs would fail the same way, so a scan
+			// of a store that can't reach itself waits for one timeout, not one per URL.
+			if ( untrailingslashit( $url ) === untrailingslashit( home_url( '/' ) ) ) {
+				$this->reachable = false;
+			}
+			return null;
+		}
+		$headers = array();
+		foreach ( array( 'location', 'x-robots-tag' ) as $name ) {
+			$value = wp_remote_retrieve_header( $response, $name );
+			$value = is_array( $value ) ? implode( ', ', $value ) : (string) $value;
+			if ( '' !== $value ) {
+				$headers[ $name ] = $value;
+			}
+		}
 		$body     = (string) wp_remote_retrieve_body( $response );
 		$length   = wp_remote_retrieve_header( $response, 'content-length' );
 		$declared = is_string( $length ) && is_numeric( $length ) && (int) $length > strlen( $body );
-		$cut      = strlen( $body ) >= $limit || $declared;
-		if ( strlen( $body ) > $limit ) {
-			$body = substr( $body, 0, $limit );
-		}
 		return array(
-			'status'    => $status,
-			'body'      => $body,
-			'truncated' => $cut,
+			'status'    => (int) wp_remote_retrieve_response_code( $response ),
+			'body'      => strlen( $body ) > $limit ? substr( $body, 0, $limit ) : $body,
+			'headers'   => $headers,
+			'truncated' => strlen( $body ) >= $limit || $declared,
 		);
 	}

 	/**
-	 * Whether a path or URL is on the store's host. Paths from the home URL are;
-	 * a //host/path reference is not, since a sitemap location must be a full URL.
-	 * Used so a robots.txt Sitemap line on another host is skipped, not treated
-	 * as a failed load of the store's own pages.
+	 * A response that answered 200, else null.
+	 *
+	 * @param array{status:int,body:string,headers:array<string,string>,truncated:bool}|null $response Response.
+	 * @return array{status:int,body:string,headers:array<string,string>,truncated:bool}|null
+	 */
+	private static function ok( ?array $response ): ?array {
+		return null !== $response && 200 === $response['status'] ? $response : null;
+	}
+
+	/**
+	 * A path from the home URL as a URL; a URL as it is.
 	 *
 	 * @param string $url URL or path.
 	 */
-	public static function is_store_url( string $url ): bool {
-		if ( str_starts_with( $url, '/' ) ) {
-			return ! str_starts_with( $url, '//' );
-		}
-		$store = self::origin( home_url() );
-		return null !== $store && self::origin( $url ) === $store;
+	private static function absolute( string $url ): string {
+		return str_starts_with( $url, '/' ) ? home_url( $url ) : $url;
 	}

 	/**
diff --git a/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/ServedOutputChecksTest.php b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/ServedOutputChecksTest.php
new file mode 100644
index 00000000000..0cbf6f79b0d
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/ServedOutputChecksTest.php
@@ -0,0 +1,351 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\AgentReadiness;
+
+use Automattic\WooCommerce\Internal\AgentReadiness\StoreOutputChecks;
+use Automattic\WooCommerce\StoreApi\Utilities\Ucp\Utils as UcpUtils;
+use WC_Helper_Product;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the store checks that read what the store serves to agents: llms.txt,
+ * Open Graph and meta tags, organization schema, snippet rules, raw HTML and UCP.
+ */
+class ServedOutputChecksTest extends WC_Unit_Test_Case {
+
+	private const SERVED_CHECKS = array( 'llms_txt_present', 'llms_txt_valid', 'llms_txt_links_resolve', 'open_graph_tags', 'meta_description', 'canonical_url', 'organization_schema', 'ai_snippet_directives', 'content_in_raw_html' );
+
+	/**
+	 * The System Under Test.
+	 *
+	 * @var StoreOutputChecks
+	 */
+	private $sut;
+
+	/**
+	 * The product whose page the checks read.
+	 *
+	 * @var \WC_Product
+	 */
+	private $product;
+
+	/**
+	 * Requests made in the current test, as "METHOD path".
+	 *
+	 * @var array<int, string>
+	 */
+	private array $requested = array();
+
+	/**
+	 * Pretty permalinks, UCP off and one product, as on a live store.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		$this->set_permalink_structure( '/%postname%/' );
+		update_option( 'woocommerce_feature_ucp_enabled', 'no' );
+		$this->product = WC_Helper_Product::create_simple_product( true, array( 'name' => 'Blue & Gold Mug' ) );
+		$this->sut     = wc_get_container()->get( StoreOutputChecks::class );
+	}
+
+	/**
+	 * Restore plain permalinks, which live on the global rewrite object outside the rollback.
+	 */
+	public function tearDown(): void {
+		try {
+			$this->set_permalink_structure( '' );
+		} finally {
+			parent::tearDown();
+		}
+	}
+
+	/**
+	 * Answer requests by path from the home URL, or by "POST path". A null response fails like a timeout; any other path is a 404.
+	 *
+	 * @param array<string, array{0:int,1:string,2?:array<string,string>}|null> $responses Status, body and headers by path.
+	 */
+	private function serve( array $responses ): void {
+		remove_all_filters( 'pre_http_request' );
+		$this->requested = array();
+		add_filter(
+			'pre_http_request',
+			function ( $pre, $args, $url ) use ( $responses ) {
+				unset( $pre );
+				$path              = substr( $url, strlen( untrailingslashit( home_url() ) ) );
+				$key               = 'POST' === $args['method'] ? 'POST ' . $path : $path;
+				$this->requested[] = $key;
+				if ( array_key_exists( $key, $responses ) && null === $responses[ $key ] ) {
+					return new \WP_Error( 'http_request_failed', 'Timed out' );
+				}
+				$response = $responses[ $key ] ?? array( 404, '' );
+				return array(
+					'headers'  => new \WpOrg\Requests\Utility\CaseInsensitiveDictionary( $response[2] ?? array() ),
+					'body'     => $response[1],
+					'response' => array(
+						'code'    => $response[0],
+						'message' => '',
+					),
+					'cookies'  => array(),
+				);
+			},
+			10,
+			3
+		);
+	}
+
+	/**
+	 * Path of the product's page from the home URL.
+	 */
+	private function product_path(): string {
+		return substr( (string) $this->product->get_permalink(), strlen( untrailingslashit( home_url() ) ) );
+	}
+
+	/**
+	 * A page as an SEO plugin and WooCommerce print it, with extra head markup.
+	 *
+	 * @param string $head Extra head markup, after the defaults.
+	 * @param string $body Body markup.
+	 * @param bool   $tags Whether to print the default Open Graph, description and canonical tags.
+	 */
+	private static function page( string $head = '', string $body = '', bool $tags = true ): string {
+		$defaults = $tags ? '<meta property="og:title" content="Shop"><meta property="og:image" content="https://example.org/a.jpg"><meta name="description" content="Mugs and more"><link rel="canonical" href="https://example.org/">' : '';
+		return '<!doctype html><html><head><title>Shop</title>' . $defaults . $head . '</head><body>' . $body . '</body></html>';
+	}
+
+	/**
+	 * Every response a store that does everything right serves.
+	 *
+	 * @return array<string, array{0:int,1:string,2?:array<string,string>}>
+	 */
+	private function good_store(): array {
+		$organization = '<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"WebSite","name":"Shop"},{"@type":"OnlineStore","name":"Shop"}]}</script>';
+		return array(
+			'/'                   => array( 200, self::page( $organization . '<meta name="robots" content="index, follow, max-snippet:-1">' ) ),
+			$this->product_path() => array( 200, self::page( '', '<h1>Blue &amp; Gold Mug</h1><p class="price"><span><bdi><span>&#36;</span>10.00</bdi></span></p>' ) ),
+			'/llms.txt'           => array( 200, "# Shop\n\n> Mugs.\n\n- [About](" . home_url( '/about/' ) . ")\n- [Elsewhere](https://example.com/x)\n- [Buy](" . home_url( '/?add-to-cart=1' ) . ")\n" ),
+			'/about/'             => array( 200, self::page() ),
+		);
+	}
+
+	/**
+	 * Results of the served checks by id.
+	 *
+	 * @return array<string, bool|null>
+	 */
+	private function results(): array {
+		return array_intersect_key( array_column( $this->sut->all(), 'ok', 'id' ), array_flip( array_merge( self::SERVED_CHECKS, array( 'ucp_profile', 'ucp_profile_version', 'ucp_endpoints_live' ) ) ) );
+	}
+
+	/**
+	 * @testdox On a store that serves everything, every served check passes, links to other sites or with a query string aren't loaded, and UCP checks are left out while UCP is off.
+	 */
+	public function test_good_store_passes(): void {
+		$this->serve( $this->good_store() );
+
+		$results = $this->results();
+
+		$this->assertSame( array_fill_keys( self::SERVED_CHECKS, true ), $results );
+		$this->assertNotContains( 'https://example.com/x', $this->requested, 'Links to other sites are not loaded' );
+		$this->assertNotContains( '/?add-to-cart=1', $this->requested, 'Links with a query string are not loaded' );
+		$this->assertArrayNotHasKey( 'ucp_profile', $results );
+	}
+
+	/**
+	 * @testdox A store whose pages carry none of the tags fails each check, and a page served for every URL is no llms.txt.
+	 */
+	public function test_bare_store_fails(): void {
+		$this->serve(
+			array(
+				'/'                   => array( 200, self::page( '', '', false ) ),
+				$this->product_path() => array( 200, self::page( '<script>document.write("Blue & Gold Mug")</script>', '<div id="app"></div>', false ) ),
+				'/llms.txt'           => array( 200, self::page( '', '', false ) ),
+			)
+		);
+
+		$this->assertSame(
+			array(
+				'llms_txt_present'       => false,
+				'llms_txt_valid'         => false,
+				'llms_txt_links_resolve' => false,
+				'open_graph_tags'        => false,
+				'meta_description'       => false,
+				'canonical_url'          => false,
+				'organization_schema'    => false,
+				'ai_snippet_directives'  => true,
+				'content_in_raw_html'    => false,
+			),
+			$this->results()
+		);
+	}
+
+	/**
+	 * @testdox llms.txt without a heading fails the format check; a broken store link fails, an unreachable or blocked one is couldn't check, and a blocked llms.txt is too.
+	 */
+	public function test_llms_txt(): void {
+		$responses              = $this->good_store();
+		$responses['/llms.txt'] = array( 200, "Shop\n- [About](" . home_url( '/about/' ) . ")\n- [Old](" . home_url( '/old/' ) . ")\n" );
+		$responses['/old/']     = array( 404, '' );
+		$this->serve( $responses );
+		$results = $this->results();
+		$this->assertFalse( $results['llms_txt_valid'] );
+		$this->assertFalse( $results['llms_txt_links_resolve'] );
+
+		$responses['/old/'] = null;
+		$this->serve( $responses );
+		$this->assertNull( $this->results()['llms_txt_links_resolve'] );
+		$this->assertContains( '/llms.txt', $this->requested );
+		$this->assertTrue( $this->results()['open_graph_tags'], 'A slow link does not stop the other checks' );
+
+		$responses['/old/'] = array( 403, 'Blocked' );
+		$this->serve( $responses );
+		$this->assertNull( $this->results()['llms_txt_links_resolve'], 'A firewall refusing the store its own page is not a broken link' );
+
+		$responses['/llms.txt'] = array( 403, 'Blocked' );
+		$this->serve( $responses );
+		$results = $this->results();
+		$this->assertNull( $results['llms_txt_present'], 'A firewall refusing llms.txt is not a missing llms.txt' );
+		$this->assertNull( $results['llms_txt_valid'] );
+		$this->assertNull( $results['llms_txt_links_resolve'] );
+	}
+
+	/**
+	 * @testdox A nosnippet rule for one crawler, or a max-snippet limit in the X-Robots-Tag header, fails the snippet check.
+	 */
+	public function test_snippet_directives(): void {
+		$responses      = $this->good_store();
+		$responses['/'] = array( 200, self::page( '<meta name="googlebot" content="nosnippet">' ) );
+		$this->serve( $responses );
+		$this->assertFalse( $this->results()['ai_snippet_directives'] );
+
+		$responses['/'] = array( 200, self::page(), array( 'X-Robots-Tag' => 'googlebot: max-snippet:50' ) );
+		$this->serve( $responses );
+		$this->assertFalse( $this->results()['ai_snippet_directives'] );
+	}
+
+	/**
+	 * @testdox With a price only in JSON-LD, the raw HTML check fails.
+	 */
+	public function test_price_only_in_script(): void {
+		$responses                          = $this->good_store();
+		$responses[ $this->product_path() ] = array( 200, self::page( '<script type="application/ld+json">{"@type":"Product","offers":{"price":"10.00"}}</script>', '<h1>Blue &amp; Gold Mug</h1>' ) );
+		$this->serve( $responses );
+
+		$this->assertFalse( $this->results()['content_in_raw_html'] );
+
+		$responses[ $this->product_path() ] = array( 200, self::page( '', '<h1>Blue &amp; Gold Mug</h1><!-- 10.00 --><p>100</p>' ) );
+		$this->serve( $responses );
+		$this->assertFalse( $this->results()['content_in_raw_html'], 'A price in a comment, or a longer number, does not count' );
+	}
+
+	/**
+	 * @testdox The price is matched as the store formats it, here with trailing zeros trimmed.
+	 */
+	public function test_price_in_store_format(): void {
+		add_filter( 'woocommerce_price_trim_zeros', '__return_true' );
+		$responses                          = $this->good_store();
+		$responses[ $this->product_path() ] = array( 200, self::page( '', '<h1>Blue &amp; Gold Mug</h1><p class="price">&#36;10</p>' ) );
+		$this->serve( $responses );
+
+		$this->assertTrue( $this->results()['content_in_raw_html'] );
+	}
+
+	/**
+	 * @testdox When the home page times out, nothing else is loaded and every served check is couldn't check; with only the home page, the page checks read it alone.
+	 */
+	public function test_couldnt_check(): void {
+		$this->serve( array( '/' => null ) );
+		$this->assertSame( array_fill_keys( self::SERVED_CHECKS, null ), $this->results() );
+		$this->assertSame( array( '/' ), $this->requested, 'Nothing else is requested when the home page times out' );
+
+		$this->serve( array( '/' => $this->good_store()['/'] ) );
+		$results = $this->results();
+		$this->assertTrue( $results['open_graph_tags'] );
+		$this->assertNull( $results['canonical_url'] );
+		$this->assertNull( $results['content_in_raw_html'] );
+	}
+
+	/**
+	 * @testdox With UCP on, the profile and catalog search checks read the served profile and POST to the endpoint it names.
+	 */
+	public function test_ucp_checks(): void {
+		update_option( 'woocommerce_feature_ucp_enabled', 'yes' );
+		$endpoint = home_url( '/wp-json/wc/ucp/v1' );
+		$path     = substr( $endpoint, strlen( untrailingslashit( home_url() ) ) );
+		$service  = array(
+			'transport' => 'rest',
+			'endpoint'  => $endpoint,
+		);
+		$profile  = wp_json_encode(
+			array(
+				'ucp' => array(
+					'version'  => UcpUtils::VERSION,
+					'services' => array( 'dev.ucp.shopping' => array( $service ) ),
+				),
+			)
+		);
+		$search   = 'POST ' . $path . '/catalog/search';
+
+		$responses                     = $this->good_store();
+		$responses['/.well-known/ucp'] = array( 200, (string) $profile );
+		$responses[ $search ]          = array( 200, '{"ucp":{},"products":[]}' );
+		$this->serve( $responses );
+		$results = $this->results();
+		$this->assertTrue( $results['ucp_profile'] );
+		$this->assertTrue( $results['ucp_profile_version'] );
+		$this->assertTrue( $results['ucp_endpoints_live'] );
+
+		$responses['/.well-known/ucp'] = array( 200, (string) str_replace( UcpUtils::VERSION, '2025-01-01', (string) $profile ) );
+		$this->serve( $responses );
+		$this->assertFalse( $this->results()['ucp_profile_version'], 'A profile for another UCP version is likely another plugin\'s' );
+
+		$responses['/.well-known/ucp'] = array( 404, '' );
+		$responses[ $search ]          = array( 403, '<html>Blocked</html>' );
+		$this->serve( $responses );
+		$results = $this->results();
+		$this->assertFalse( $results['ucp_profile'] );
+		$this->assertNull( $results['ucp_profile_version'], 'No profile is reported by ucp_profile alone' );
+		$this->assertFalse( $results['ucp_endpoints_live'], 'Without a profile, WooCommerce\'s endpoint is tried' );
+	}
+
+	/**
+	 * @testdox Checks for output WooCommerce doesn't build yet don't run, and are reported as pending.
+	 */
+	public function test_pending_checks(): void {
+		update_option( 'woocommerce_feature_ucp_enabled', 'yes' );
+		$this->serve( $this->good_store() );
+
+		$checks = array_column( $this->sut->all(), null, 'id' );
+
+		foreach ( array( 'product_return_policy_schema', 'product_shipping_schema', 'product_buy_action_schema', 'ucp_checkout' ) as $id ) {
+			$this->assertNull( $checks[ $id ]['ok'], $id );
+			$this->assertTrue( $checks[ $id ]['pending'], $id );
+		}
+		$this->assertFalse( $checks['open_graph_tags']['pending'] );
+	}
+
+	/**
+	 * @testdox The pending product checks find a returns policy and shipping details on an offer, and a BuyAction on the product or an offer.
+	 */
+	public function test_pending_product_detectors(): void {
+		$offer_has  = new \ReflectionMethod( StoreOutputChecks::class, 'offer_has' );
+		$buy_action = new \ReflectionMethod( StoreOutputChecks::class, 'has_buy_action' );
+		$offer_has->setAccessible( true );
+		$buy_action->setAccessible( true );
+		$product = array(
+			'@type'  => 'Product',
+			'offers' => array(
+				array(
+					'@type'                   => 'Offer',
+					'hasMerchantReturnPolicy' => array( '@type' => 'MerchantReturnPolicy' ),
+					'potentialAction'         => array( '@type' => 'BuyAction' ),
+				),
+			),
+		);
+
+		$this->assertTrue( $offer_has->invoke( null, array( $product ), 'hasMerchantReturnPolicy' ) );
+		$this->assertFalse( $offer_has->invoke( null, array( $product ), 'shippingDetails' ) );
+		$this->assertTrue( $buy_action->invoke( null, array( $product ) ) );
+		$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' ) ) ) ) );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/StoreOutputChecksTest.php b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/StoreOutputChecksTest.php
index b0965835ee6..633070a07b7 100644
--- a/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/StoreOutputChecksTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/StoreOutputChecksTest.php
@@ -48,9 +48,9 @@ class StoreOutputChecksTest extends WC_Unit_Test_Case {
 	}

 	/**
-	 * Answer requests by path from the home URL; anything else fails like a timeout.
+	 * Answer requests by path from the home URL; a null response fails like a timeout, and any other path is a 404.
 	 *
-	 * @param array<string, array{0:int,1:string}> $responses Status and body by path.
+	 * @param array<string, array{0:int,1:string}|null> $responses Status and body by path.
 	 */
 	private function serve( array $responses ): void {
 		add_filter(
@@ -58,14 +58,15 @@ class StoreOutputChecksTest extends WC_Unit_Test_Case {
 			static function ( $pre, $args, $url ) use ( $responses ) {
 				unset( $pre, $args );
 				$path = substr( $url, strlen( untrailingslashit( home_url() ) ) );
-				if ( ! isset( $responses[ $path ] ) ) {
+				if ( array_key_exists( $path, $responses ) && null === $responses[ $path ] ) {
 					return new \WP_Error( 'http_request_failed', 'Timed out' );
 				}
+				$response = $responses[ $path ] ?? array( 404, '' );
 				return array(
 					'headers'  => array(),
-					'body'     => $responses[ $path ][1],
+					'body'     => $response[1],
 					'response' => array(
-						'code'    => $responses[ $path ][0],
+						'code'    => $response[0],
 						'message' => '',
 					),
 					'cookies'  => array(),
@@ -89,7 +90,7 @@ class StoreOutputChecksTest extends WC_Unit_Test_Case {
 	 * @testdox When the store cannot load its own pages, the sitemap check is couldn't check.
 	 */
 	public function test_unreachable_store_couldnt_check(): void {
-		$this->serve( array() );
+		self::block_requests();

 		$this->assertNull( $this->results()['sitemap_lists_products'] );
 	}
@@ -137,7 +138,8 @@ class StoreOutputChecksTest extends WC_Unit_Test_Case {
 		for ( $i = 1; $i <= 50; $i++ ) {
 			$robots .= 'Sitemap: ' . home_url( "/s$i.xml" ) . "\n";
 		}
-		$this->serve( array( '/robots.txt' => array( 200, $robots ) ) );
+		$served = array_fill_keys( array_map( static fn( int $i ): string => "/s$i.xml", range( 1, 50 ) ), array( 404, '' ) );
+		$this->serve( $served + array( '/robots.txt' => array( 200, $robots ) ) );
 		add_filter(
 			'pre_http_request',
 			static function ( $pre, $args, $url ) use ( &$requested ) {
@@ -151,6 +153,7 @@ class StoreOutputChecksTest extends WC_Unit_Test_Case {

 		$this->results();

+		$requested = array_filter( $requested, static fn( string $url ): bool => (bool) preg_match( '#(robots\.txt|\.xml)$#', $url ) );
 		$this->assertCount( 8, array_unique( $requested ), 'robots.txt, five listed sitemaps, /sitemap_index.xml and /sitemap.xml' );
 	}

@@ -339,7 +342,7 @@ class StoreOutputChecksTest extends WC_Unit_Test_Case {
 		$this->assertFalse( $this->results()['sitemap_lists_products'], 'A product category sitemap does not list products' );

 		remove_all_filters( 'pre_http_request' );
-		$this->serve( array() );
+		self::block_requests();
 		$this->assertNull( $this->results()['sitemap_lists_products'] );
 	}

@@ -407,6 +410,64 @@ class StoreOutputChecksTest extends WC_Unit_Test_Case {
 		$this->assertSame( array( true, true, true ), $this->product_results() );
 	}

+	/**
+	 * @testdox The product page checks skip a newer product of a type the report doesn't check.
+	 */
+	public function test_product_checks_skip_unchecked_types(): 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 ) ) ) ) ) );
+		$grouped = new \WC_Product_Grouped();
+		$grouped->set_name( 'Newer grouped product' );
+		$grouped->save();
+
+		$this->assertSame( array( true, true, true ), $this->product_results() );
+	}
+
+	/**
+	 * @testdox A shop-only product is sampled. A newer search-only product is not.
+	 */
+	public function test_product_checks_include_shop_only_products(): void {
+		$product = WC_Helper_Product::create_simple_product();
+		$product->set_catalog_visibility( 'catalog' );
+		$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() );
+		$search_only = WC_Helper_Product::create_simple_product();
+		$search_only->set_catalog_visibility( 'search' );
+		$search_only->save();
+		$this->serve(
+			array(
+				self::path_of( $product )     => array( 200, self::page_with( array( self::core_product_jsonld( $product ) ) ) ),
+				self::path_of( $search_only ) => array( 200, self::page_with( array() ) ),
+			)
+		);
+
+		$this->assertSame( array( true, true, true ), $this->product_results() );
+	}
+
+	/**
+	 * @testdox A store with only grouped and external products could not be checked, rather than failed.
+	 */
+	public function test_unsupported_product_types_are_not_a_failure(): void {
+		$grouped = new \WC_Product_Grouped();
+		$grouped->set_name( 'Grouped product' );
+		$grouped->save();
+		$external = WC_Helper_Product::create_external_product();
+		$this->serve(
+			array(
+				self::path_of( $grouped )  => array( 200, self::page_with( array() ) ),
+				self::path_of( $external ) => array( 200, self::page_with( array() ) ),
+			)
+		);
+
+		$this->assertSame( array( null, null, null ), $this->product_results() );
+	}
+
 	/**
 	 * @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.
 	 */
@@ -443,6 +504,8 @@ class StoreOutputChecksTest extends WC_Unit_Test_Case {
 		$this->assertSame( array( null, null, null ), $this->product_results(), 'No product' );

 		$product = WC_Helper_Product::create_simple_product();
+		remove_all_filters( 'pre_http_request' );
+		$this->serve( array( self::path_of( $product ) => null ) );
 		$this->assertSame( array( null, null, null ), $this->product_results(), 'Unreachable' );

 		remove_all_filters( 'pre_http_request' );
diff --git a/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/StoreResponsesTest.php b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/StoreResponsesTest.php
index c6c0cbbb666..fdf9f9d55a5 100644
--- a/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/StoreResponsesTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/StoreResponsesTest.php
@@ -74,14 +74,19 @@ class StoreResponsesTest extends WC_Unit_Test_Case {
 	}

 	/**
-	 * @testdox A failed request is null, so the check says couldn't check; a 404 is a response.
+	 * @testdox A failed request is null, so the check says couldn't check; a 404 is a response. After the home page fails no more requests are made.
 	 */
 	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'] );
+		$this->assertNull( $sut->get( '/timeout' ) );
+		$this->assertSame( 404, $sut->get( '/missing' )['status'], 'One slow page does not stop the scan' );
+		$this->assertNull( $sut->home_page() );
+		$this->assertNull( $sut->get( '/other' ) );
+		$this->assertNull( $sut->post_json( '/missing-post', array() ) );
+		$this->assertSame( array( home_url( '/missing' ), home_url( '/timeout' ), home_url( '/' ) ), $this->requested );
 	}

 	/**
@@ -105,6 +110,16 @@ class StoreResponsesTest extends WC_Unit_Test_Case {
 		$this->assertSame( array( home_url( '/a' ), home_url( '/b' ), home_url( '/c' ) ), $this->requested );
 	}

+	/**
+	 * @testdox A redirect to a query string on the store host is not requested.
+	 */
+	public function test_redirect_with_query_string_is_not_followed(): void {
+		$this->serve( array( home_url( '/go' ) => self::response( 302, '', array( 'Location' => '/?add-to-cart=12' ) ) ) );
+
+		$this->assertNull( ( new StoreResponses( true ) )->get( '/go' ) );
+		$this->assertSame( array( home_url( '/go' ) ), $this->requested );
+	}
+
 	/**
 	 * @testdox A redirect loop on the store's host gives up after two redirects.
 	 */