Commit 2dedbc7169c for woocommerce
commit 2dedbc7169ce626d3c884814c827cb65baed1f4e
Author: AnnaMag <AnnaMag@users.noreply.github.com>
Date: Fri Nov 13 21:57:31 2026 +0100
Agent readiness: product structured data store checks (#70440)
* Agent readiness: product structured data store checks
Part of #70440.
* 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: keep footer JSON-LD on a long product page.
Read product pages up to 2 MB so the footer script is not cut off at 512 KB, and treat a short read with no Product JSON-LD as couldn't check. Document that null, and move the SEO JSON-LD case to a plain unit test.
Co-authored-by: Cursor <cursoragent@cursor.com>
* Agent readiness: import WC_Helper_Product in StoreResponsesTest
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
diff --git a/plugins/woocommerce/changelog/add-agent-readiness-product-schema b/plugins/woocommerce/changelog/add-agent-readiness-product-schema
new file mode 100644
index 00000000000..1285605614c
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-agent-readiness-product-schema
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+Comment: Agent readiness: product structured data checks, behind an experimental flag; nothing ships to merchants.
+
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/JsonLd.php b/plugins/woocommerce/src/Internal/AgentReadiness/JsonLd.php
new file mode 100644
index 00000000000..06bce81a94b
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/JsonLd.php
@@ -0,0 +1,81 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\AgentReadiness;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Reads the JSON-LD blocks in a page, for store checks that look for schema.org
+ * items whoever added them: WooCommerce, an SEO plugin or the theme.
+ *
+ * @since 11.4.0
+ */
+class JsonLd {
+
+ /**
+ * The schema.org items of a type in a page's JSON-LD: items at the top of a block,
+ * in its `@graph`, or the `mainEntity` of one of those. Blocks that don't parse are skipped.
+ *
+ * @param string $html Page HTML.
+ * @param string ...$types Types to match, such as Product.
+ * @return array<int, array<string, mixed>>
+ */
+ public static function items( string $html, string ...$types ): array {
+ if ( ! preg_match_all( '#<script\b[^>]*\btype\s*=\s*["\']?application/ld\+json["\']?[^>]*>(.*?)</script>#is', $html, $blocks ) ) {
+ return array();
+ }
+ $items = array();
+ foreach ( $blocks[1] as $block ) {
+ $data = json_decode( trim( $block ), true );
+ if ( ! is_array( $data ) ) {
+ continue;
+ }
+ foreach ( self::top_level( $data ) as $item ) {
+ $items[] = $item;
+ if ( isset( $item['mainEntity'] ) && is_array( $item['mainEntity'] ) ) {
+ array_push( $items, ...self::top_level( $item['mainEntity'] ) );
+ }
+ }
+ }
+ return array_values( array_filter( $items, static fn( array $item ): bool => self::is_type( $item, $types ) ) );
+ }
+
+ /**
+ * Whether an item has one of the types, written as `Product`, `schema:Product` or `https://schema.org/Product`.
+ *
+ * @param array<string, mixed> $item Item.
+ * @param array<int, string> $types Types.
+ */
+ private static function is_type( array $item, array $types ): bool {
+ foreach ( (array) ( $item['@type'] ?? array() ) as $type ) {
+ if ( is_string( $type ) && in_array( preg_replace( '#^.*[/:]#', '', $type ), $types, true ) ) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * An item, a list of items or an object with a `@graph`, as a list of items.
+ *
+ * @param array<mixed> $data Decoded JSON.
+ * @return array<int, array<string, mixed>>
+ */
+ private static function top_level( array $data ): array {
+ $list = wp_is_numeric_array( $data ) ? $data : array( $data );
+ $items = array();
+ foreach ( $list as $item ) {
+ if ( ! is_array( $item ) ) {
+ continue;
+ }
+ if ( isset( $item['@graph'] ) && is_array( $item['@graph'] ) ) {
+ array_push( $items, ...array_filter( array_values( $item['@graph'] ), 'is_array' ) );
+ }
+ if ( isset( $item['@type'] ) ) {
+ $items[] = $item;
+ }
+ }
+ return $items;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/README.md b/plugins/woocommerce/src/Internal/AgentReadiness/README.md
index 760491e8157..e8ad6768ae5 100644
--- a/plugins/woocommerce/src/Internal/AgentReadiness/README.md
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/README.md
@@ -56,13 +56,16 @@ Store-wide conditions (`StoreConditions`) are reported first because they affect
## Store output checks
-`StoreOutputChecks` runs in the background scan only. It loads the store's own pages as a logged-out visitor (`StoreResponses`): only URLs on the store's host, redirects followed on that host only, a 5 second timeout and 512 KB per response, each URL once per scan, nothing at all in coming soon mode. 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.
+`StoreOutputChecks` runs in the background scan only. It loads the store's own pages as a logged-out visitor (`StoreResponses`): only URLs on the store's host, redirects followed on that host only, a 5 second timeout and 512 KB per response, each URL once per scan, nothing at all in coming soon mode. A product page is read up to 2 MB, because its JSON-LD is printed in the footer and the usual cap can cut it off. Its results go into the report's `store_checks` with the same keys, with `ok` set to `null` when the store couldn't load its own page ("Couldn't check"), or when that read was cut off before any Product JSON-LD. They are always `recommended`, so they never block the UCP switch.
| Id | Passes when |
| --- | --- |
| `sitemap_lists_products` | A sitemap listed in robots.txt, or `/wp-sitemap.xml`, `/sitemap_index.xml` or `/sitemap.xml`, names a product sitemap or product pages. An index that does not has its child sitemaps opened, at any depth, up to five child requests. |
+| `product_jsonld_present` | The newest product page has Product (or ProductGroup) JSON-LD that parses, from WooCommerce, an SEO plugin or the theme. |
+| `product_jsonld_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.
+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".
## Report format
@@ -76,7 +79,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.
+ // ok: true, false, or null. Null means the page could not be loaded, or a product field check found no Product JSON-LD (the settings page hides that).
array( 'id' => 'launched', 'ok' => true, 'blocks' => 'listing', 'level' => 'required', 'name' => '…', 'label' => '…', 'why' => '…', 'fix' => '…', 'fix_url' => '…' ),
),
'channels' => array(
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/StoreOutputChecks.php b/plugins/woocommerce/src/Internal/AgentReadiness/StoreOutputChecks.php
index e8863747197..6d0d098fa05 100644
--- a/plugins/woocommerce/src/Internal/AgentReadiness/StoreOutputChecks.php
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/StoreOutputChecks.php
@@ -14,7 +14,8 @@ defined( 'ABSPATH' ) || exit;
* 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
+ * when the store could not load its own page (couldn't check). The product field
+ * checks are also null when the page has no Product JSON-LD. These checks are
* always recommended: a result that can be "couldn't check" never blocks UCP.
*
* @since 11.4.0
@@ -99,6 +100,36 @@ class StoreOutputChecks {
'fix' => __( 'Turn on the sitemap in your SEO plugin, or remove the code that turns off the WordPress sitemap, and include products in it.', 'woocommerce' ),
'fix_url' => '',
),
+ array(
+ 'id' => 'product_jsonld_present',
+ 'test' => fn( StoreResponses $responses ): ?bool => $this->with_products( $responses, static fn( array $products ): bool => array() !== $products ),
+ 'blocks' => 'listing',
+ 'name' => __( 'The newest product page has product structured data', 'woocommerce' ),
+ 'label' => __( 'The newest product page has no product structured data', 'woocommerce' ),
+ 'why' => __( 'Shopping crawlers and search engines read the product\'s JSON-LD to get its name, price and stock without guessing from the page.', 'woocommerce' ),
+ 'fix' => __( 'WooCommerce adds it to the product page. Check whether your theme or an SEO plugin removes it, or turn on product schema in your SEO plugin.', 'woocommerce' ),
+ 'fix_url' => '',
+ ),
+ array(
+ 'id' => 'product_jsonld_required_fields',
+ 'test' => fn( StoreResponses $responses ): ?bool => $this->with_products( $responses, static fn( array $products ): ?bool => array() === $products ? null : self::has_required_fields( $products ) ),
+ 'blocks' => 'listing',
+ 'name' => __( 'Product structured data has a name, image and offer', 'woocommerce' ),
+ 'label' => __( 'Product structured data is missing a name, image or offer', 'woocommerce' ),
+ 'why' => __( 'Without these, agents and search engines can\'t show the product as something to buy.', 'woocommerce' ),
+ 'fix' => __( 'Give the product a name, an image and a price. If it has them, check the product schema settings in your SEO plugin.', 'woocommerce' ),
+ 'fix_url' => '',
+ ),
+ array(
+ 'id' => 'product_offer_jsonld',
+ 'test' => fn( StoreResponses $responses ): ?bool => $this->with_products( $responses, static fn( array $products ): ?bool => array() === $products ? null : self::has_priced_offer( $products ) ),
+ 'blocks' => 'listing',
+ 'name' => __( 'Product offer has a price and stock status', 'woocommerce' ),
+ 'label' => __( 'Product offer is missing a price or stock status', 'woocommerce' ),
+ 'why' => __( 'Agents compare price and availability before they suggest a product.', 'woocommerce' ),
+ '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' => '',
+ ),
);
}
@@ -224,6 +255,94 @@ class StoreOutputChecks {
return $urls;
}
+ /**
+ * Run a test on the Product items in a product page's JSON-LD, or null when no product page could be loaded.
+ *
+ * @param StoreResponses $responses Store responses.
+ * @param callable $test Test, given the Product items.
+ */
+ private function with_products( StoreResponses $responses, callable $test ): ?bool {
+ $page = $responses->product_page();
+ if ( null === $page ) {
+ return null;
+ }
+ $products = JsonLd::items( $page['body'], 'Product', 'ProductGroup' );
+ // A cut-off page can hide the footer JSON-LD. That is "couldn't check", not "missing".
+ if ( array() === $products && $page['truncated'] ) {
+ return null;
+ }
+ $result = $test( $products );
+ return null === $result ? null : (bool) $result;
+ }
+
+ /**
+ * Whether the Product items give a name, an image and an offer, from one source or several.
+ *
+ * @param array<int, array<string, mixed>> $products Product items.
+ */
+ private static function has_required_fields( array $products ): bool {
+ foreach ( array( 'name', 'image', 'offers' ) as $field ) {
+ if ( array() === array_filter( $products, static fn( array $product ): bool => ! empty( $product[ $field ] ) ) ) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Whether one offer of the Product items has both a price and an availability. A price
+ * can be the offer's price, an AggregateOffer's lowPrice or a price specification's price.
+ *
+ * @param array<int, array<string, mixed>> $products Product items.
+ */
+ private static function has_priced_offer( array $products ): bool {
+ $offers = array();
+ foreach ( $products as $product ) {
+ array_push( $offers, ...self::as_list( $product['offers'] ?? null ) );
+ }
+ // An AggregateOffer can list its offers; read them too, one level down.
+ $nested = array();
+ foreach ( $offers as $offer ) {
+ array_push( $nested, ...self::as_list( $offer['offers'] ?? null ) );
+ }
+ foreach ( array_merge( $offers, $nested ) as $offer ) {
+ $prices = array_merge( array( $offer['price'] ?? null, $offer['lowPrice'] ?? null ), array_column( self::as_list( $offer['priceSpecification'] ?? null ), 'price' ) );
+ if ( ! empty( $offer['availability'] ) && array() !== array_filter( $prices, array( self::class, 'has_price_amount' ) ) ) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Whether a JSON-LD price is a number, or text that contains one (`10.00`, `10,00`, `10.00 USD`).
+ *
+ * @param mixed $value Price value.
+ */
+ private static function has_price_amount( $value ): bool {
+ if ( is_int( $value ) || is_float( $value ) ) {
+ return is_finite( $value );
+ }
+ if ( ! is_string( $value ) ) {
+ return false;
+ }
+ $normalized = str_replace( array( ',', ' ', "\u{00A0}" ), array( '.', '', '' ), $value );
+ return 1 === preg_match( '/-?\d+(?:\.\d+)?/', $normalized );
+ }
+
+ /**
+ * A JSON-LD value that may be one object or a list of them, as a list of objects.
+ *
+ * @param mixed $value Value.
+ * @return array<int, array<string, mixed>>
+ */
+ private static function as_list( $value ): array {
+ if ( ! is_array( $value ) ) {
+ return array();
+ }
+ return array_values( array_filter( wp_is_numeric_array( $value ) ? $value : array( $value ), 'is_array' ) );
+ }
+
/**
* Whether a sitemap index names a product sitemap (product-sitemap.xml,
* wp-sitemap-posts-product-1.xml), or a urlset lists a product page. A category
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/StoreResponses.php b/plugins/woocommerce/src/Internal/AgentReadiness/StoreResponses.php
index aa6caff149b..fc90c9da225 100644
--- a/plugins/woocommerce/src/Internal/AgentReadiness/StoreResponses.php
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/StoreResponses.php
@@ -3,6 +3,9 @@ declare( strict_types = 1 );
namespace Automattic\WooCommerce\Internal\AgentReadiness;
+use Automattic\WooCommerce\Enums\CatalogVisibility;
+use Automattic\WooCommerce\Enums\ProductStatus;
+
defined( 'ABSPATH' ) || exit;
/**
@@ -14,6 +17,9 @@ defined( 'ABSPATH' ) || exit;
* 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".
*
+ * 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.
+ *
* @since 11.4.0
*/
class StoreResponses {
@@ -22,6 +28,12 @@ class StoreResponses {
public const MAX_BYTES = 512 * KB_IN_BYTES;
+ /**
+ * How much of a product page is downloaded. Product JSON-LD is printed in the
+ * footer, so the usual cap can cut it off.
+ */
+ public const PRODUCT_PAGE_BYTES = 2 * MB_IN_BYTES;
+
private const MAX_REDIRECTS = 2;
/**
@@ -34,10 +46,17 @@ class StoreResponses {
/**
* Responses by URL.
*
- * @var array<string, array{status:int,body:string}|null>
+ * @var array<string, array{status:int,body:string,truncated:bool}|null>
*/
private array $responses = array();
+ /**
+ * URL of the product page checks read, '' when there is none, null until looked up.
+ *
+ * @var string|null
+ */
+ private ?string $product_url = null;
+
/**
* Constructor.
*
@@ -51,7 +70,7 @@ 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}|null Null when the store could not be reached.
+ * @return array{status:int,body:string,truncated:bool}|null Null when the store could not be reached.
*/
public function get( string $url ): ?array {
if ( str_starts_with( $url, '/' ) ) {
@@ -63,13 +82,54 @@ class StoreResponses {
return $this->responses[ $url ];
}
+ /**
+ * 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.
+ *
+ * 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
+ */
+ public function product_page(): ?array {
+ if ( null === $this->product_url ) {
+ $this->product_url = '';
+ $products = wc_get_products(
+ array(
+ 'status' => ProductStatus::PUBLISH,
+ 'visibility' => CatalogVisibility::VISIBLE,
+ 'limit' => 5,
+ 'orderby' => 'date',
+ 'order' => 'DESC',
+ )
+ );
+ foreach ( is_array( $products ) ? $products : array() as $product ) {
+ if ( $product instanceof \WC_Product && '' === $product->get_post_password() ) {
+ $this->product_url = (string) $product->get_permalink();
+ break;
+ }
+ }
+ }
+ if ( '' === $this->product_url ) {
+ 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;
+ }
+ $response = $this->responses[ $this->product_url ];
+ return null !== $response && 200 === $response['status'] ? $response : null;
+ }
+
/**
* Request a URL, following redirects that stay on the store's host.
*
- * @param string $url URL.
- * @return array{status:int,body:string}|null
+ * @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
*/
- private function fetch( string $url ): ?array {
+ private function fetch( string $url, bool $product_page = false ): ?array {
+ $limit = $product_page ? self::PRODUCT_PAGE_BYTES : self::MAX_BYTES;
$store = self::origin( home_url() );
for ( $redirects = 0; $redirects <= self::MAX_REDIRECTS; $redirects++ ) {
if ( null === $store || self::origin( $url ) !== $store ) {
@@ -80,7 +140,7 @@ class StoreResponses {
array(
'timeout' => self::TIMEOUT,
'redirection' => 0,
- 'limit_response_size' => self::MAX_BYTES,
+ '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 ),
)
@@ -91,16 +151,37 @@ class StoreResponses {
$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 ),
- );
+ return $this->capped_response( $response, $status, $limit );
}
$url = \WP_Http::make_absolute_url( $location, $url );
}
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.
+ *
+ * @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}
+ */
+ private function capped_response( array $response, int $status, int $limit ): array {
+ $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,
+ );
+ }
+
/**
* 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.
diff --git a/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/JsonLdTest.php b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/JsonLdTest.php
new file mode 100644
index 00000000000..d665543d656
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/JsonLdTest.php
@@ -0,0 +1,55 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\AgentReadiness;
+
+use Automattic\WooCommerce\Internal\AgentReadiness\JsonLd;
+
+/**
+ * Tests for reading JSON-LD blocks. The fixtures are HTML strings, so this is not a WC_Unit_Test_Case.
+ */
+class JsonLdTest extends \PHPUnit\Framework\TestCase {
+
+ /**
+ * @testdox An SEO plugin's @graph is read, a schema: type counts as Product, and a block that doesn't parse is skipped.
+ */
+ public function test_seo_plugin_graph_is_read(): void {
+ $graph = array(
+ '@context' => 'https://schema.org',
+ '@graph' => array(
+ array(
+ '@type' => 'WebPage',
+ 'name' => 'Dummy Product',
+ ),
+ array(
+ '@type' => array( 'schema:Product' ),
+ 'name' => 'Dummy Product',
+ 'image' => array( 'https://example.org/image.jpg' ),
+ 'offers' => array(
+ '@type' => 'AggregateOffer',
+ 'offers' => array(
+ array(
+ '@type' => 'Offer',
+ 'priceSpecification' => array( 'price' => '10.00' ),
+ 'availability' => 'https://schema.org/InStock',
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ $html = '<!doctype html><html><head>';
+ $html .= '<script type="application/ld+json">{ not json</script>';
+ $html .= '<script type="application/ld+json">' . wp_json_encode( $graph ) . '</script>';
+ $html .= '</head><body></body></html>';
+
+ $items = JsonLd::items( $html, 'Product', 'ProductGroup' );
+
+ $this->assertCount( 1, $items );
+ $this->assertSame( 'Dummy Product', $items[0]['name'] );
+ $this->assertSame( array( 'https://example.org/image.jpg' ), $items[0]['image'] );
+ $this->assertSame( 'AggregateOffer', $items[0]['offers']['@type'] );
+ $this->assertSame( '10.00', $items[0]['offers']['offers'][0]['priceSpecification']['price'] );
+ $this->assertSame( 'https://schema.org/InStock', $items[0]['offers']['offers'][0]['availability'] );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/StoreOutputChecksTest.php b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/StoreOutputChecksTest.php
index 0513eb07957..b0965835ee6 100644
--- a/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/StoreOutputChecksTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/StoreOutputChecksTest.php
@@ -4,6 +4,8 @@ declare( strict_types = 1 );
namespace Automattic\WooCommerce\Tests\Internal\AgentReadiness;
use Automattic\WooCommerce\Internal\AgentReadiness\StoreOutputChecks;
+use WC_Helper_Product;
+use WC_Structured_Data;
use WC_Unit_Test_Case;
/**
@@ -340,4 +342,149 @@ class StoreOutputChecksTest extends WC_Unit_Test_Case {
$this->serve( array() );
$this->assertNull( $this->results()['sitemap_lists_products'] );
}
+
+ /**
+ * Path of a product's page from the home URL.
+ *
+ * @param \WC_Product $product Product.
+ */
+ private static function path_of( \WC_Product $product ): string {
+ return substr( (string) $product->get_permalink(), strlen( untrailingslashit( home_url() ) ) );
+ }
+
+ /**
+ * A page with a JSON-LD block for each item.
+ *
+ * @param array<int, array|string> $blocks Decoded JSON-LD, or a raw block body.
+ */
+ private static function page_with( array $blocks ): string {
+ $html = '<!doctype html><html><head><title>Product</title>';
+ foreach ( $blocks as $block ) {
+ $html .= '<script type="application/ld+json">' . ( is_string( $block ) ? $block : wp_json_encode( $block ) ) . '</script>';
+ }
+ return $html . '</head><body></body></html>';
+ }
+
+ /**
+ * The JSON-LD WooCommerce prints on a product's page.
+ *
+ * @param \WC_Product $product Product.
+ */
+ private static function core_product_jsonld( \WC_Product $product ): string {
+ $structured_data = new WC_Structured_Data();
+ $structured_data->generate_product_data( $product );
+ return wc_esc_json( (string) wp_json_encode( $structured_data->get_structured_data( array() ), JSON_HEX_TAG | JSON_UNESCAPED_SLASHES ), true );
+ }
+
+ /**
+ * Results of the three product structured data checks.
+ *
+ * @return array<int, bool|null>
+ */
+ private function product_results(): array {
+ $results = $this->results();
+ return array( $results['product_jsonld_present'], $results['product_jsonld_required_fields'], $results['product_offer_jsonld'] );
+ }
+
+ /**
+ * @testdox The JSON-LD WooCommerce prints for a simple and a variable product passes all three product checks.
+ *
+ * @testWith ["simple"]
+ * ["variable"]
+ *
+ * @param string $type Product type.
+ */
+ public function test_core_product_jsonld_passes( string $type ): void {
+ $product = 'simple' === $type ? WC_Helper_Product::create_simple_product() : WC_Helper_Product::create_variation_product();
+ $product->set_image_id( self::factory()->attachment->create_object( array( 'file' => 'image.jpg' ) ) );
+ $product->save();
+ if ( $product instanceof \WC_Product_Variable ) {
+ \WC_Product_Variable::sync( $product );
+ }
+ $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 ) ) ) ) ) );
+
+ $this->assertSame( array( true, true, true ), $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.
+ */
+ public function test_missing_product_jsonld(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ $path = self::path_of( $product );
+ $this->serve( array( $path => array( 200, self::page_with( array( array( '@type' => 'BreadcrumbList' ) ) ) ) ) );
+ $this->assertSame( array( false, null, null ), $this->product_results() );
+
+ remove_all_filters( 'pre_http_request' );
+ $no_price = array(
+ '@type' => 'Product',
+ 'name' => 'Dummy Product',
+ 'image' => 'https://example.org/image.jpg',
+ 'offers' => array(
+ '@type' => 'Offer',
+ 'availability' => 'https://schema.org/InStock',
+ ),
+ );
+ $this->serve( array( $path => array( 200, self::page_with( array( $no_price ) ) ) ) );
+ $this->assertSame( array( true, true, false ), $this->product_results() );
+
+ remove_all_filters( 'pre_http_request' );
+ $no_price['offers']['price'] = '10.00 USD';
+ $this->serve( array( $path => array( 200, self::page_with( array( $no_price ) ) ) ) );
+ $this->assertSame( array( true, true, true ), $this->product_results(), 'A price with a currency code counts' );
+ }
+
+ /**
+ * @testdox With no product, an unreachable product page or an error page, the product checks are couldn't check.
+ */
+ public function test_product_checks_couldnt_check(): void {
+ $this->serve( array() );
+ $this->assertSame( array( null, null, null ), $this->product_results(), 'No product' );
+
+ $product = WC_Helper_Product::create_simple_product();
+ $this->assertSame( array( null, null, null ), $this->product_results(), 'Unreachable' );
+
+ remove_all_filters( 'pre_http_request' );
+ $this->serve( array( self::path_of( $product ) => array( 503, self::page_with( array( self::core_product_jsonld( $product ) ) ) ) ) );
+ $this->assertSame( array( null, null, null ), $this->product_results(), 'Error page' );
+
+ remove_all_filters( 'pre_http_request' );
+ add_filter(
+ 'pre_http_request',
+ static function () {
+ return array(
+ 'headers' => array( 'content-length' => '99999999' ),
+ 'body' => '<html><body>No product data here</body></html>',
+ 'response' => array(
+ 'code' => 200,
+ 'message' => 'OK',
+ ),
+ 'cookies' => array(),
+ );
+ }
+ );
+ $this->assertSame( array( null, null, null ), $this->product_results(), 'Page cut off before the footer' );
+ }
+
+ /**
+ * @testdox A password protected product is skipped for the newest product shoppers can open.
+ */
+ public function test_password_protected_product_skipped(): void {
+ $open = WC_Helper_Product::create_simple_product();
+ $open->set_date_created( time() - HOUR_IN_SECONDS );
+ $open->set_image_id( self::factory()->attachment->create_object( array( 'file' => 'image.jpg' ) ) );
+ $open->save();
+ $locked = WC_Helper_Product::create_simple_product();
+ $locked->set_post_password( 'secret' );
+ $locked->save();
+ $this->serve(
+ array(
+ self::path_of( $open ) => array( 200, self::page_with( array( self::core_product_jsonld( $open ) ) ) ),
+ self::path_of( $locked ) => array( 200, self::page_with( array() ) ),
+ )
+ );
+
+ $this->assertSame( array( true, true, true ), $this->product_results() );
+ }
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/StoreResponsesTest.php b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/StoreResponsesTest.php
index 9b9429dcbb3..c6c0cbbb666 100644
--- a/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/StoreResponsesTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/StoreResponsesTest.php
@@ -4,6 +4,7 @@ declare( strict_types = 1 );
namespace Automattic\WooCommerce\Tests\Internal\AgentReadiness;
use Automattic\WooCommerce\Internal\AgentReadiness\StoreResponses;
+use WC_Helper_Product;
use WC_Unit_Test_Case;
/**
@@ -114,6 +115,50 @@ class StoreResponsesTest extends WC_Unit_Test_Case {
$this->assertCount( 3, $this->requested );
}
+ /**
+ * @testdox A product page is read past the usual cap, so JSON-LD in the footer is still there.
+ */
+ public function test_product_page_keeps_footer_past_the_byte_cap(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ $marker = '<script type="application/ld+json">{"@type":"Product","name":"Tail"}</script>';
+ $body = str_repeat( 'x', StoreResponses::MAX_BYTES ) . $marker;
+ $limit = null;
+ add_filter(
+ 'pre_http_request',
+ static function ( $pre, $args ) use ( &$limit ) {
+ $limit = $args['limit_response_size'];
+ return $pre;
+ },
+ 9,
+ 2
+ );
+ $this->serve( array( (string) $product->get_permalink() => self::response( 200, $body ) ) );
+
+ $page = ( new StoreResponses( true ) )->product_page();
+
+ $this->assertSame( StoreResponses::PRODUCT_PAGE_BYTES, $limit );
+ $this->assertNotNull( $page );
+ $this->assertStringContainsString( $marker, $page['body'] );
+ $this->assertFalse( $page['truncated'] );
+ }
+
+ /**
+ * @testdox A product page whose response was cut short is marked truncated.
+ */
+ public function test_product_page_cut_short_is_truncated(): void {
+ $product = WC_Helper_Product::create_simple_product();
+ $this->serve(
+ array(
+ (string) $product->get_permalink() => self::response( 200, '<html></html>', array( 'content-length' => '99999999' ) ),
+ )
+ );
+
+ $page = ( new StoreResponses( true ) )->product_page();
+
+ $this->assertNotNull( $page );
+ $this->assertTrue( $page['truncated'] );
+ }
+
/**
* @testdox In coming soon mode nothing is requested: visitors would get the coming soon page.
*/