Commit 5cbc7492f3e for woocommerce
commit 5cbc7492f3e7ae8e48df508213bcb5d623d8a0f8
Author: AnnaMag <AnnaMag@users.noreply.github.com>
Date: Tue Nov 10 10:48:53 2026 +0100
Add an Agentic commerce settings page behind the agent readiness flag (#70388)
* Add an Agentic commerce settings page behind the agent readiness flag
Part of #70388.
* Agent readiness: leave a blank line before the group list
Stylelint requires an empty line before a rule. The list inside .wc-agentic-commerce-group was the classic-assets lint failure.
* Agent readiness: keep the UCP switch off while a required setup check fails
A failing required check refuses the turn-on, and the POST answers 409 so the page can say why. Turning the switch off still works.
* Agent readiness: keep the UCP endpoint's return type compatible with PHP 7.4
The 409 response stays in the docblock. A native union type fails the plugin lint.
* 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: show the privacy page field only to users who can manage privacy options
The privacy policy page is the WordPress wp_page_for_privacy_policy option,
which Settings > Privacy guards with manage_privacy_options. A shop manager
could change it from this tab; the field is now left out of the tab, and so
out of its save, for users without that capability.
Also use the UCP utilities from their new namespace.
* Agent readiness: use a valid effect for the later date in the effect_from test
A rules file with an unknown dated effect is no longer stored, so the test's 'rejected' value left nothing to read.
* Drop the Plain permalinks check from agentic commerce setup
With #70680, UCP works on Plain permalinks too, so they should not
keep merchants from turning UCP on.
diff --git a/plugins/woocommerce/changelog/add-agentic-commerce-settings b/plugins/woocommerce/changelog/add-agentic-commerce-settings
new file mode 100644
index 00000000000..ccbc9d2feb2
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-agentic-commerce-settings
@@ -0,0 +1,3 @@
+Significance: patch
+Type: dev
+Comment: Experimental Agentic commerce settings page with the UCP switch; behind a hidden flag, no merchant change.
diff --git a/plugins/woocommerce/client/legacy/css/admin.scss b/plugins/woocommerce/client/legacy/css/admin.scss
index c4d8f11a582..46d6d8f2003 100644
--- a/plugins/woocommerce/client/legacy/css/admin.scss
+++ b/plugins/woocommerce/client/legacy/css/admin.scss
@@ -9728,3 +9728,261 @@ body.woocommerce-settings-payments-section_legacy {
}
}
}
+
+// Settings > Agentic commerce.
+.wc-agentic-commerce {
+ max-inline-size: 720px;
+
+ h2,
+ h3 {
+ margin: 0;
+ }
+
+ .wc-agentic-commerce-card {
+ margin-block: 0 16px;
+ background: #fff;
+ border: 1px solid #dcdcde;
+ border-radius: 4px;
+
+ > h2,
+ > div[id$="-description"],
+ > .form-table,
+ > .description {
+ margin-inline: 24px;
+ }
+
+ > h2 {
+ margin-block-start: 24px;
+ }
+
+ > .description {
+ margin-block-end: 24px;
+ }
+ }
+
+ .wc-agentic-commerce-card__header {
+ padding: 24px 24px 16px;
+
+ p {
+ margin: 4px 0 0;
+ color: #50575e;
+ }
+
+ &--split {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 16px;
+ }
+ }
+
+ .wc-agentic-commerce-card__body,
+ .wc-agentic-commerce-card__footer {
+ margin: 0;
+ padding: 16px 24px;
+ }
+
+ .wc-agentic-commerce-card__footer {
+ border-block-start: 1px solid #f0f0f1;
+ color: #50575e;
+ }
+
+ .wc-agentic-commerce-card--split {
+ display: grid;
+ grid-template-columns: 2fr 1fr;
+
+ > div {
+ padding: 24px;
+ }
+
+ > div + div {
+ border-inline-start: 1px solid #f0f0f1;
+ }
+
+ h3 {
+ margin-block-end: 8px;
+ font-size: 11px;
+ text-transform: uppercase;
+ }
+ }
+
+ .wc-agentic-commerce-placeholder p {
+ color: #757575;
+ }
+
+ .wc-agentic-commerce-policies .form-table {
+ display: block;
+ inline-size: auto;
+
+ tbody {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 0 16px;
+ }
+
+ tr {
+ display: flex;
+ flex-direction: column;
+ }
+
+ th,
+ td {
+ padding: 4px 0;
+ }
+
+ select {
+ inline-size: 100%;
+ min-inline-size: 0 !important;
+ }
+ }
+}
+
+.wc-agentic-commerce-switch-card {
+ padding: 24px;
+
+ .wc-agentic-commerce-switch-row {
+ display: flex;
+ align-items: flex-start;
+ gap: 12px;
+ }
+
+ p {
+ margin: 4px 0 0;
+ }
+
+ .wc-agentic-commerce-ucp-message:empty {
+ display: none;
+ }
+}
+
+.wc-agentic-commerce-notice {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-block-start: 16px;
+ padding: 12px 16px;
+ background: #fcf9e8;
+ border: 1px solid #f0c33c;
+ border-radius: 2px;
+
+ p {
+ flex: 1;
+ margin: 0 !important;
+ }
+
+ .dashicons {
+ color: #8a6100;
+ }
+}
+
+.wc-agentic-commerce-counts {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ margin: 0;
+ border-block-start: 1px solid #f0f0f1;
+
+ li {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ margin: 0;
+ padding: 16px 24px;
+ }
+
+ li + li {
+ border-inline-start: 1px solid #f0f0f1;
+ }
+
+ strong {
+ font-size: 1.6em;
+ font-weight: 400;
+ line-height: 1.2;
+ }
+}
+
+.wc-agentic-commerce-group {
+
+ ul {
+ margin: 0;
+ }
+
+ &__title {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin: 0;
+ padding: 10px 24px;
+ font-size: 13px;
+ background: #f6f7f7;
+ border-block: 1px solid #f0f0f1;
+ }
+
+ &--done summary {
+ cursor: pointer;
+ }
+}
+
+.wc-agentic-commerce-count {
+ padding: 0 6px;
+ border-radius: 8px;
+ background: #dcdcde;
+ font-size: 11px;
+}
+
+.wc-agentic-commerce-check {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin: 0;
+ padding: 12px 24px;
+ border-block-end: 1px solid #f0f0f1;
+
+ &__text {
+ flex: 1;
+ font-weight: 600;
+ }
+
+ &__icon {
+ inline-size: 18px;
+ block-size: 18px;
+ border: 2px solid #a7aaad;
+ border-radius: 50%;
+ }
+
+ &--required &__icon {
+ border-color: #b26200;
+ }
+
+ &--done .dashicons {
+ color: $green;
+ }
+}
+
+.wc-agentic-commerce-badge {
+ display: inline-block;
+ padding: 0 8px;
+ border-radius: 2px;
+ font-weight: 600;
+ background: #f0f0f1;
+ color: #1d2327;
+
+ &--pass {
+ background: #edfaef;
+ color: $green;
+ }
+
+ &--fix {
+ background: #fcf0e1;
+ color: #8a4d00;
+ }
+
+ &--check {
+ background: #fcf9e8;
+ color: #8a6100;
+ }
+}
+
+.wc-agentic-commerce-ucp-switch {
+ flex: none;
+ margin-block-start: 2px;
+}
diff --git a/plugins/woocommerce/client/legacy/js/admin/wc-agentic-commerce-settings.js b/plugins/woocommerce/client/legacy/js/admin/wc-agentic-commerce-settings.js
new file mode 100644
index 00000000000..b929ee32e8e
--- /dev/null
+++ b/plugins/woocommerce/client/legacy/js/admin/wc-agentic-commerce-settings.js
@@ -0,0 +1,72 @@
+( function () {
+ 'use strict';
+
+ const settings = window.wcAgenticCommerceSettings;
+ if ( ! settings || ! window.wp || ! window.wp.apiFetch ) {
+ return;
+ }
+
+ const toggle = document.querySelector( '.wc-agentic-commerce-ucp-switch' );
+ const knob = toggle && toggle.querySelector( '.woocommerce-input-toggle' );
+ const message = document.querySelector( '.wc-agentic-commerce-ucp-message' );
+ const statusBadge = document.querySelector( '.wc-agentic-commerce-status' );
+
+ const render = ( enabled ) => {
+ toggle.setAttribute( 'aria-checked', enabled ? 'true' : 'false' );
+ knob.classList.toggle( 'woocommerce-input-toggle--enabled', enabled );
+ knob.classList.toggle( 'woocommerce-input-toggle--disabled', ! enabled );
+ };
+
+ const renderStatus = ( status ) => {
+ if ( ! statusBadge || ! settings.i18n.statusLabel[ status ] ) {
+ return;
+ }
+ statusBadge.textContent = settings.i18n.statusLabel[ status ];
+ const badges = { active: 'pass', off: 'off', needs_attention: 'fix' };
+ Object.values( badges ).forEach( ( badge ) =>
+ statusBadge.classList.remove( 'wc-agentic-commerce-badge--' + badge )
+ );
+ statusBadge.classList.add(
+ 'wc-agentic-commerce-badge--' + ( badges[ status ] || 'off' )
+ );
+ };
+
+ if ( toggle && knob && message ) {
+ toggle.addEventListener( 'click', async () => {
+ const enable = toggle.getAttribute( 'aria-checked' ) !== 'true';
+ toggle.disabled = true;
+ knob.classList.add( 'woocommerce-input-toggle--loading' );
+ try {
+ const response = await window.wp.apiFetch( {
+ path: settings.path,
+ method: 'POST',
+ data: { enabled: enable },
+ } );
+ render( response.enabled );
+ renderStatus( response.status );
+ message.textContent = response.enabled ? settings.i18n.on : '';
+ } catch ( error ) {
+ render( ! enable );
+ message.textContent = ( error && error.message ) || settings.i18n.error;
+ } finally {
+ toggle.disabled = false;
+ knob.classList.remove( 'woocommerce-input-toggle--loading' );
+ }
+ } );
+ }
+
+ document.querySelectorAll( '.wc-agentic-commerce-copy' ).forEach( ( button ) => {
+ button.addEventListener( 'click', async () => {
+ const input = document.getElementById( button.dataset.copyTarget );
+ if ( ! input || ! navigator.clipboard ) {
+ return;
+ }
+ const label = button.textContent;
+ await navigator.clipboard.writeText( input.value );
+ button.textContent = settings.i18n.copied;
+ setTimeout( () => {
+ button.textContent = label;
+ }, 2000 );
+ } );
+ } );
+} )();
diff --git a/plugins/woocommerce/includes/class-woocommerce.php b/plugins/woocommerce/includes/class-woocommerce.php
index 8c03b51600d..c946d959a3e 100644
--- a/plugins/woocommerce/includes/class-woocommerce.php
+++ b/plugins/woocommerce/includes/class-woocommerce.php
@@ -460,6 +460,7 @@ final class WooCommerce {
$container->get( Automattic\WooCommerce\Internal\AgentReadiness\StatusTab::class )->register();
$container->get( Automattic\WooCommerce\Internal\AgentReadiness\ScheduledScan::class )->register();
$container->get( Automattic\WooCommerce\Internal\AgentReadiness\ReadinessRestController::class )->register();
+ $container->get( Automattic\WooCommerce\Internal\AgentReadiness\AgenticCommerceSettings::class )->register();
$container->get( Automattic\WooCommerce\Internal\Admin\OrderTaxLookupMigrator::class )->register();
$container->get( Automattic\WooCommerce\Internal\ProductDownloads\DownloadableFileAttachments::class )->register();
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/AgenticCommerceSettings.php b/plugins/woocommerce/src/Internal/AgentReadiness/AgenticCommerceSettings.php
new file mode 100644
index 00000000000..6b244891a6b
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/AgenticCommerceSettings.php
@@ -0,0 +1,115 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\AgentReadiness;
+
+use Automattic\WooCommerce\Internal\Features\FeaturesController;
+use Automattic\WooCommerce\Internal\RegisterHooksInterface;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Adds the WooCommerce > Settings > Agentic commerce tab while the
+ * agent_readiness feature is enabled, and names the options it saves.
+ *
+ * @since 11.4.0
+ */
+class AgenticCommerceSettings implements RegisterHooksInterface {
+
+ public const TAB = 'agentic-commerce';
+
+ public const PRIVACY_PAGE_OPTION = 'wp_page_for_privacy_policy';
+
+ public const TERMS_PAGE_OPTION = 'woocommerce_terms_page_id';
+
+ public const REFUND_RETURNS_PAGE_OPTION = 'woocommerce_refund_returns_page_id';
+
+ public const SHIPPING_POLICY_PAGE_OPTION = 'woocommerce_shipping_policy_page_id';
+
+ public const FAQ_PAGE_OPTION = 'woocommerce_faq_page_id';
+
+ /**
+ * UCP Phase 1 sends it as the warranty_policy link. Not in the settings design yet.
+ */
+ public const WARRANTY_PAGE_OPTION = 'woocommerce_warranty_page_id';
+
+ /**
+ * Features controller.
+ *
+ * @var FeaturesController
+ */
+ private FeaturesController $features_controller;
+
+ /**
+ * Store conditions.
+ *
+ * @var StoreConditions
+ */
+ private StoreConditions $store_conditions;
+
+ /**
+ * Readiness service.
+ *
+ * @var AgentReadiness
+ */
+ private AgentReadiness $agent_readiness;
+
+ /**
+ * UCP switch.
+ *
+ * @var UcpSwitch
+ */
+ private UcpSwitch $ucp_switch;
+
+ /**
+ * Initialize dependencies.
+ *
+ * @internal
+ *
+ * @param FeaturesController $features_controller Features controller.
+ * @param StoreConditions $store_conditions Store conditions.
+ * @param AgentReadiness $agent_readiness Readiness service.
+ * @param UcpSwitch $ucp_switch UCP switch.
+ */
+ final public function init( FeaturesController $features_controller, StoreConditions $store_conditions, AgentReadiness $agent_readiness, UcpSwitch $ucp_switch ): void {
+ $this->features_controller = $features_controller;
+ $this->store_conditions = $store_conditions;
+ $this->agent_readiness = $agent_readiness;
+ $this->ucp_switch = $ucp_switch;
+ }
+
+ /**
+ * Register hooks. The feature check waits for init, since feature definitions load translations.
+ */
+ public function register(): void {
+ add_action( 'init', array( $this, 'handle_init' ) );
+ }
+
+ /**
+ * Add the settings page when the feature is enabled.
+ *
+ * @internal
+ */
+ public function handle_init(): void {
+ if ( ! is_admin() || ! $this->features_controller->feature_is_enabled( StatusTab::FEATURE_ID ) ) {
+ return;
+ }
+ add_filter( 'woocommerce_get_settings_pages', array( $this, 'handle_woocommerce_get_settings_pages' ) );
+ }
+
+ /**
+ * Add the Agentic commerce page to the settings pages.
+ *
+ * @internal
+ *
+ * @param mixed $pages Settings pages.
+ * @return mixed
+ */
+ public function handle_woocommerce_get_settings_pages( $pages ) {
+ if ( ! is_array( $pages ) || ! class_exists( \WC_Settings_Page::class, false ) ) {
+ return $pages;
+ }
+ $pages[] = new AgenticCommerceSettingsPage( $this->store_conditions, $this->agent_readiness, $this->ucp_switch );
+ return $pages;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/AgenticCommerceSettingsPage.php b/plugins/woocommerce/src/Internal/AgentReadiness/AgenticCommerceSettingsPage.php
new file mode 100644
index 00000000000..8c9c8d4ac29
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/AgenticCommerceSettingsPage.php
@@ -0,0 +1,514 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\AgentReadiness;
+
+use Automattic\WooCommerce\Enums\ProductType;
+use Automattic\WooCommerce\StoreApi\Utilities\Ucp\Utils as UcpUtils;
+use Automattic\WooCommerce\Internal\Traits\ScriptDebug;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * WooCommerce > Settings > Agentic commerce. The UCP switch saves on its own
+ * through wc/v3/agent-readiness/ucp; the store policy fields save with
+ * Save changes. Only created by AgenticCommerceSettings while the
+ * agent_readiness feature is enabled, after WC_Settings_Page has loaded.
+ *
+ * @since 11.4.0
+ */
+class AgenticCommerceSettingsPage extends \WC_Settings_Page {
+
+ use ScriptDebug;
+
+ private const SCRIPT_HANDLE = 'wc-agentic-commerce-settings';
+
+ private const POLICIES_SECTION = 'agentic_commerce_policies';
+
+ /**
+ * Marks the page dropdowns on this tab, which share options with other tabs.
+ */
+ private const PAGE_FIELD_KEY = 'agentic_commerce_page';
+
+ /**
+ * Store conditions.
+ *
+ * @var StoreConditions
+ */
+ private StoreConditions $store_conditions;
+
+ /**
+ * Readiness service.
+ *
+ * @var AgentReadiness
+ */
+ private AgentReadiness $agent_readiness;
+
+ /**
+ * UCP switch.
+ *
+ * @var UcpSwitch
+ */
+ private UcpSwitch $ucp_switch;
+
+ /**
+ * Constructor.
+ *
+ * @param StoreConditions $store_conditions Store conditions.
+ * @param AgentReadiness $agent_readiness Readiness service.
+ * @param UcpSwitch $ucp_switch UCP switch.
+ */
+ public function __construct( StoreConditions $store_conditions, AgentReadiness $agent_readiness, UcpSwitch $ucp_switch ) {
+ $this->id = AgenticCommerceSettings::TAB;
+ $this->label = __( 'Agentic commerce', 'woocommerce' );
+ $this->store_conditions = $store_conditions;
+ $this->agent_readiness = $agent_readiness;
+ $this->ucp_switch = $ucp_switch;
+
+ parent::__construct();
+
+ add_filter( 'woocommerce_admin_settings_sanitize_option', array( $this, 'sanitize_page_option' ), 10, 3 );
+ add_action( 'woocommerce_settings_' . self::POLICIES_SECTION . '_after', array( $this, 'render_policies_footnote' ) );
+ }
+
+ /**
+ * The store policy fields. The privacy policy page is a WordPress setting, so it is
+ * listed, and saved, only for users who can manage privacy options.
+ *
+ * @return array<int, array<string, mixed>>
+ */
+ protected function get_settings_for_default_section() {
+ $store_pages = StoreConditions::store_page_ids();
+ $pages = array( '' => __( 'Select a page…', 'woocommerce' ) );
+ $published = get_pages(
+ array(
+ 'post_status' => 'publish',
+ 'exclude' => $store_pages,
+ )
+ );
+ foreach ( is_array( $published ) ? $published : array() as $page ) {
+ $pages[ (string) $page->ID ] = $page->post_title;
+ }
+ $page_field = static function ( string $option, string $title, string $desc = '' ) use ( $pages, $store_pages ): array {
+ $options = $pages;
+ $stored = get_post( (int) get_option( $option ) );
+ // A stored page that can't be chosen here stays listed, so saving another field keeps it.
+ if ( $stored instanceof \WP_Post && 'page' === $stored->post_type && ! isset( $options[ (string) $stored->ID ] ) ) {
+ $status = get_post_status_object( $stored->post_status );
+ $state = in_array( $stored->ID, $store_pages, true ) ? __( 'Store page', 'woocommerce' ) : ( $status ? $status->label : $stored->post_status );
+ /* translators: 1: page title, 2: page status, such as Draft, or "Store page". */
+ $options[ (string) $stored->ID ] = sprintf( __( '%1$s (%2$s)', 'woocommerce' ), $stored->post_title, $state );
+ }
+ return array(
+ 'title' => $title,
+ 'desc' => $desc,
+ 'id' => $option,
+ 'type' => 'select',
+ 'options' => $options,
+ 'default' => '',
+ 'css' => 'min-width:300px;',
+ 'autoload' => in_array( $option, array( AgenticCommerceSettings::PRIVACY_PAGE_OPTION, AgenticCommerceSettings::TERMS_PAGE_OPTION ), true ),
+ self::PAGE_FIELD_KEY => true,
+ );
+ };
+
+ return array(
+ array(
+ 'title' => __( 'Store policies', 'woocommerce' ),
+ 'type' => 'title',
+ 'desc' => __( 'Choose the pages AI agents will link shoppers to before they buy, so they know your return, shipping and privacy terms up front.', 'woocommerce' ),
+ 'id' => self::POLICIES_SECTION,
+ ),
+ $page_field( AgenticCommerceSettings::REFUND_RETURNS_PAGE_OPTION, __( 'Refund and returns', 'woocommerce' ) ),
+ $page_field( AgenticCommerceSettings::SHIPPING_POLICY_PAGE_OPTION, __( 'Shipping policy', 'woocommerce' ) ),
+ ...( current_user_can( 'manage_privacy_options' ) ? array( $page_field( AgenticCommerceSettings::PRIVACY_PAGE_OPTION, __( 'Privacy policy', 'woocommerce' ) ) ) : array() ),
+ $page_field( AgenticCommerceSettings::TERMS_PAGE_OPTION, __( 'Terms and conditions', 'woocommerce' ) ),
+ $page_field( AgenticCommerceSettings::FAQ_PAGE_OPTION, __( 'FAQ', 'woocommerce' ), __( 'Optional.', 'woocommerce' ) ),
+ $page_field( AgenticCommerceSettings::WARRANTY_PAGE_OPTION, __( 'Warranty', 'woocommerce' ), __( 'Optional.', 'woocommerce' ) ),
+ array(
+ 'type' => 'sectionend',
+ 'id' => self::POLICIES_SECTION,
+ ),
+ );
+ }
+
+ /**
+ * Render the page as the design's cards: the switch, what agents can see, Status,
+ * the store policy fields, the store address and Security.
+ */
+ public function output(): void {
+ $checks = $this->store_conditions->all();
+ $failing = $this->store_conditions->failing_required( $checks );
+
+ $this->enqueue_script( count( $failing ) );
+
+ echo '<div class="wc-agentic-commerce">';
+ echo '<p class="wc-agentic-commerce-intro">' . esc_html__( 'Let AI shopping assistants find your products, build a cart, and send shoppers to your checkout to pay. Uses the Universal Commerce Protocol (UCP).', 'woocommerce' ) . '</p>';
+ $this->render_switch( $checks, count( $failing ) );
+ $this->render_visibility( $this->agent_readiness->get_cached_report() );
+ $this->render_status( $checks, $this->ucp_switch->status( $failing ), count( $failing ) );
+
+ echo '<div class="wc-agentic-commerce-card wc-agentic-commerce-policies">';
+ parent::output();
+ echo '</div>';
+
+ $this->render_address_and_test();
+ $this->render_security();
+ echo '</div>';
+ }
+
+ /**
+ * Accept only published pages from a page dropdown on this tab, or an empty
+ * value to clear it. A stored page that isn't published is listed in its own
+ * dropdown, so submitting it unchanged keeps it.
+ *
+ * @internal
+ *
+ * @param mixed $value Sanitized value.
+ * @param mixed $option Field definition.
+ * @param mixed $raw_value Posted value.
+ * @return mixed Null skips saving the option.
+ */
+ public function sanitize_page_option( $value, $option, $raw_value ) {
+ if ( ! is_array( $option ) || empty( $option[ self::PAGE_FIELD_KEY ] ) ) {
+ return $value;
+ }
+ $page_id = absint( is_scalar( $raw_value ) ? $raw_value : 0 );
+ if ( 0 === $page_id ) {
+ return '';
+ }
+ if ( StoreConditions::is_policy_page( $page_id ) ) {
+ return (string) $page_id;
+ }
+ return null;
+ }
+
+ /**
+ * Footnote under the store policy fields.
+ *
+ * @internal
+ */
+ public function render_policies_footnote(): void {
+ echo '<p class="description">' . esc_html__( 'Refund and returns, privacy policy and terms and conditions are the pages your store already uses at checkout and in your footer. Changing a page here changes it everywhere in your store.', 'woocommerce' ) . '</p>';
+ }
+
+ /**
+ * Product counts from the last stored readiness report.
+ *
+ * @param array<string, mixed>|null $report Stored report, or null before the first scan.
+ */
+ private function render_visibility( ?array $report ): void {
+ echo '<div class="wc-agentic-commerce-card">';
+ echo '<div class="wc-agentic-commerce-card__header"><h2>' . esc_html__( 'What agents can see', 'woocommerce' ) . '</h2>';
+ echo '<p>' . esc_html__( 'Agents see the same products shoppers see in your shop. To keep a product away from agents, set its catalog visibility to Hidden.', 'woocommerce' ) . '</p></div>';
+
+ if ( null === $report ) {
+ printf(
+ '<p class="wc-agentic-commerce-card__body">%1$s <a href="%2$s">%3$s</a></p></div>',
+ esc_html__( 'Product counts appear after the first agent readiness check.', 'woocommerce' ),
+ esc_url( admin_url( 'admin.php?page=wc-status&tab=' . StatusTab::TAB ) ),
+ esc_html__( 'Run the check', 'woocommerce' )
+ );
+ return;
+ }
+
+ $list_url = static fn( array $args ): string => add_query_arg(
+ array_merge(
+ array(
+ 'post_type' => 'product',
+ 'post_status' => 'publish',
+ ),
+ $args
+ ),
+ admin_url( 'edit.php' )
+ );
+ $supported = ProductType::SIMPLE . ',' . ProductType::VARIABLE;
+ // The products list can't filter out hidden products, so the available count has no list to link to.
+ $cards = array(
+ array(
+ 'count' => (int) ( $report['products'] ?? 0 ),
+ 'label' => __( 'Available to agents', 'woocommerce' ),
+ 'url' => '',
+ ),
+ array(
+ 'count' => (int) ( $report['hidden_from_catalog'] ?? 0 ),
+ 'label' => __( 'Hidden from your catalog', 'woocommerce' ),
+ 'url' => $list_url(
+ array(
+ 'product_type' => $supported,
+ 'product_visibility' => 'exclude-from-catalog',
+ )
+ ),
+ ),
+ array(
+ 'count' => (int) ( $report['unsupported_types'] ?? 0 ),
+ 'label' => __( 'Not supported yet', 'woocommerce' ),
+ 'url' => $list_url( array( 'product_type' => implode( ',', $this->unsupported_product_types() ) ) ),
+ ),
+ );
+
+ echo '<ul class="wc-agentic-commerce-counts">';
+ foreach ( $cards as $card ) {
+ printf(
+ '<li><span>%1$s</span> <strong>%2$s</strong>%3$s</li>',
+ esc_html( $card['label'] ),
+ esc_html( number_format_i18n( $card['count'] ) ),
+ '' === $card['url'] ? '' : ' <a href="' . esc_url( $card['url'] ) . '">' . esc_html__( 'View products', 'woocommerce' ) . '</a>'
+ );
+ }
+ echo '</ul>';
+ echo '<p class="wc-agentic-commerce-card__footer">' . esc_html__( 'Simple and variable products only, for now.', 'woocommerce' ) . '</p>';
+ echo '</div>';
+ }
+
+ /**
+ * The UCP switch card. The switch saves right away, apart from Save changes. While the
+ * store is in coming soon mode, the card says so: agents can't shop a store that isn't live.
+ *
+ * @param array<int, array{id:string,ok:bool,fix_url:string}> $checks Store conditions.
+ * @param int $failing_required Number of failing required setup checks.
+ */
+ private function render_switch( array $checks, int $failing_required ): void {
+ $enabled = UcpUtils::is_enabled();
+ $message = $enabled ? self::on_message( $failing_required ) : '';
+ $launched = current( array_filter( $checks, static fn( array $check ): bool => 'launched' === $check['id'] ) );
+ ?>
+ <div class="wc-agentic-commerce-card wc-agentic-commerce-switch-card">
+ <div class="wc-agentic-commerce-switch-row">
+ <button type="button" class="wc-agentic-commerce-ucp-switch button-link" role="switch" aria-checked="<?php echo $enabled ? 'true' : 'false'; ?>" aria-labelledby="wc-agentic-commerce-ucp-title" aria-describedby="wc-agentic-commerce-ucp-desc" <?php disabled( ! $enabled && $failing_required > 0 ); ?>>
+ <span class="woocommerce-input-toggle woocommerce-input-toggle--<?php echo $enabled ? 'enabled' : 'disabled'; ?>"><?php echo $enabled ? esc_html__( 'Yes', 'woocommerce' ) : esc_html__( 'No', 'woocommerce' ); ?></span>
+ </button>
+ <div>
+ <h2 id="wc-agentic-commerce-ucp-title"><?php esc_html_e( 'Let AI agents shop your store', 'woocommerce' ); ?></h2>
+ <p id="wc-agentic-commerce-ucp-desc"><?php esc_html_e( 'AI shopping assistants find your products and build a cart. Shoppers pay on your checkout, so you keep the order and the customer. Turn it off at any time to stop all agent access.', 'woocommerce' ); ?></p>
+ <p class="wc-agentic-commerce-ucp-message" role="status"><?php echo esc_html( $message ); ?></p>
+ </div>
+ </div>
+ <?php if ( $launched && ! $launched['ok'] ) : ?>
+ <div class="wc-agentic-commerce-notice">
+ <span class="dashicons dashicons-warning" aria-hidden="true"></span>
+ <p><?php esc_html_e( 'Your store is in coming soon mode, so agents can\'t shop it yet. Take it live to let them in.', 'woocommerce' ); ?></p>
+ <a class="button button-primary" href="<?php echo esc_url( $launched['fix_url'] ); ?>"><?php esc_html_e( 'Go to Site visibility', 'woocommerce' ); ?></a>
+ </div>
+ <?php endif; ?>
+ </div>
+ <?php
+ }
+
+ /**
+ * What the switch says while UCP is on.
+ *
+ * @param int $failing_required Number of failing required setup checks.
+ */
+ private static function on_message( int $failing_required ): string {
+ if ( $failing_required > 0 ) {
+ /* translators: %d: number of failing required setup checks. */
+ return sprintf( _n( 'AI agents can shop your store, but %d required setup check below still needs fixing.', 'AI agents can shop your store, but %d required setup checks below still need fixing.', $failing_required, 'woocommerce' ), $failing_required );
+ }
+ return __( 'AI agents can now find and shop your store.', 'woocommerce' );
+ }
+
+ /**
+ * The Status card: a badge, then the setup checks grouped into Required, Recommended and Done.
+ * Each open check says what to do and links to where to do it. UCP itself is the switch, so it is not listed.
+ *
+ * @param array<int, array{id:string,level:string,ok:bool,name:string,label:string,fix_url:string}> $checks Store conditions.
+ * @param string $status UcpSwitch status.
+ * @param int $failing_required Number of failing required setup checks.
+ */
+ private function render_status( array $checks, string $status, int $failing_required ): void {
+ $statuses = self::status_labels();
+ $classes = array(
+ UcpSwitch::STATUS_ACTIVE => 'pass',
+ UcpSwitch::STATUS_OFF => 'off',
+ UcpSwitch::STATUS_NEEDS_ATTENTION => 'fix',
+ );
+ $groups = array(
+ 'required' => array( __( 'Required', 'woocommerce' ), array() ),
+ 'recommended' => array( __( 'Recommended', 'woocommerce' ), array() ),
+ 'done' => array( __( 'Done', 'woocommerce' ), array() ),
+ );
+ foreach ( $checks as $check ) {
+ if ( 'ucp_on' === $check['id'] ) {
+ continue;
+ }
+ $group = $check['ok'] ? 'done' : ( StoreConditions::LEVEL_REQUIRED === $check['level'] ? 'required' : 'recommended' );
+ $groups[ $group ][1][] = $check;
+ }
+ $summary = $failing_required > 0
+ /* translators: %d: number of failing required setup checks. */
+ ? sprintf( _n( '%d required step to finish so agents can shop your store.', '%d required steps to finish so agents can shop your store.', $failing_required, 'woocommerce' ), $failing_required )
+ : __( 'Everything agents need is set up.', 'woocommerce' );
+ ?>
+ <div class="wc-agentic-commerce-card wc-agentic-commerce-status-card">
+ <div class="wc-agentic-commerce-card__header wc-agentic-commerce-card__header--split">
+ <div>
+ <h2><?php esc_html_e( 'Status', 'woocommerce' ); ?></h2>
+ <p><?php echo esc_html( $summary ); ?></p>
+ </div>
+ <mark class="wc-agentic-commerce-badge wc-agentic-commerce-badge--<?php echo esc_attr( $classes[ $status ] ); ?> wc-agentic-commerce-status"><?php echo esc_html( $statuses[ $status ] ); ?></mark>
+ </div>
+ <?php foreach ( $groups as $id => $group ) : ?>
+ <?php
+ if ( array() === $group[1] ) {
+ continue;
+ }
+ $heading = sprintf( '%1$s <span class="wc-agentic-commerce-count">%2$s</span>', esc_html( $group[0] ), esc_html( number_format_i18n( count( $group[1] ) ) ) );
+ ?>
+ <?php if ( 'done' === $id ) : ?>
+ <details class="wc-agentic-commerce-group wc-agentic-commerce-group--done">
+ <summary class="wc-agentic-commerce-group__title"><?php echo wp_kses_post( $heading ); ?></summary>
+ <ul>
+ <?php foreach ( $group[1] as $check ) : ?>
+ <li class="wc-agentic-commerce-check wc-agentic-commerce-check--done"><span class="dashicons dashicons-yes-alt" aria-hidden="true"></span><span><?php echo esc_html( $check['name'] ); ?></span></li>
+ <?php endforeach; ?>
+ </ul>
+ </details>
+ <?php else : ?>
+ <div class="wc-agentic-commerce-group wc-agentic-commerce-group--<?php echo esc_attr( $id ); ?>">
+ <h3 class="wc-agentic-commerce-group__title"><?php echo wp_kses_post( $heading ); ?></h3>
+ <ul>
+ <?php foreach ( $group[1] as $check ) : ?>
+ <?php $action = $this->check_action( $check ); ?>
+ <li class="wc-agentic-commerce-check wc-agentic-commerce-check--<?php echo esc_attr( $id ); ?>">
+ <span class="wc-agentic-commerce-check__icon" aria-hidden="true"></span>
+ <span class="wc-agentic-commerce-check__text"><?php echo esc_html( $action[0] ); ?></span>
+ <?php if ( '' !== $action[2] ) : ?>
+ <a class="button" href="<?php echo esc_url( $action[2] ); ?>"><?php echo esc_html( $action[1] ); ?></a>
+ <?php endif; ?>
+ </li>
+ <?php endforeach; ?>
+ </ul>
+ </div>
+ <?php endif; ?>
+ <?php endforeach; ?>
+ </div>
+ <?php
+ }
+
+ /**
+ * What an open check asks the merchant to do: the action, the button text and where it goes.
+ * A policy page that is chosen but not published links to its editor; a published store page asks for another page.
+ *
+ * @param array{id:string,label:string,fix_url:string} $check Store condition.
+ * @return array{0:string,1:string,2:string}
+ */
+ private function check_action( array $check ): array {
+ $policies = array(
+ 'privacy_page' => array( AgenticCommerceSettings::PRIVACY_PAGE_OPTION, __( 'Publish your privacy policy page', 'woocommerce' ), __( 'Choose a privacy policy page', 'woocommerce' ) ),
+ 'terms_page' => array( AgenticCommerceSettings::TERMS_PAGE_OPTION, __( 'Publish your terms and conditions page', 'woocommerce' ), __( 'Choose a terms and conditions page', 'woocommerce' ) ),
+ 'refund_returns_page' => array( AgenticCommerceSettings::REFUND_RETURNS_PAGE_OPTION, __( 'Publish your refund and returns policy page', 'woocommerce' ), __( 'Choose a refund and returns policy page', 'woocommerce' ) ),
+ 'shipping_policy_page' => array( AgenticCommerceSettings::SHIPPING_POLICY_PAGE_OPTION, __( 'Publish your shipping policy page', 'woocommerce' ), __( 'Choose a shipping policy page', 'woocommerce' ) ),
+ );
+ if ( isset( $policies[ $check['id'] ] ) ) {
+ $page_id = (int) get_option( $policies[ $check['id'] ][0] );
+ $edit = $page_id > 0 && get_post( $page_id ) && 'publish' !== get_post_status( $page_id ) ? (string) get_edit_post_link( $page_id, 'raw' ) : '';
+ return '' !== $edit
+ ? array( $policies[ $check['id'] ][1], __( 'Review and publish', 'woocommerce' ), $edit )
+ : array( $policies[ $check['id'] ][2], __( 'Choose a page', 'woocommerce' ), $check['fix_url'] );
+ }
+ $actions = array(
+ 'launched' => array( __( 'Take your store out of coming soon mode', 'woocommerce' ), __( 'Go to Site visibility', 'woocommerce' ) ),
+ 'https' => array( __( 'Move your store to HTTPS', 'woocommerce' ), __( 'Learn how', 'woocommerce' ) ),
+ 'search_visible' => array( __( 'Let search engines index your store', 'woocommerce' ), __( 'Open settings', 'woocommerce' ) ),
+ 'guest_checkout' => array( __( 'Allow guest checkout', 'woocommerce' ), __( 'Open settings', 'woocommerce' ) ),
+ 'order_emails' => array( __( 'Turn on order confirmation emails', 'woocommerce' ), __( 'Open settings', 'woocommerce' ) ),
+ );
+ $action = $actions[ $check['id'] ] ?? array( $check['label'], __( 'Open settings', 'woocommerce' ) );
+ return array( $action[0], $action[1], $check['fix_url'] );
+ }
+
+ /**
+ * The store address agents start from, and Test your setup (WOOAI-1619, not built yet).
+ */
+ private function render_address_and_test(): void {
+ ?>
+ <div class="wc-agentic-commerce-card wc-agentic-commerce-card--split">
+ <div>
+ <h3><label for="wc-agentic-commerce-ucp-url"><?php esc_html_e( 'Store address for agents', 'woocommerce' ); ?></label></h3>
+ <input type="text" id="wc-agentic-commerce-ucp-url" class="regular-text code" readonly value="<?php echo esc_attr( home_url( '/.well-known/ucp' ) ); ?>" />
+ <button type="button" class="button wc-agentic-commerce-copy" data-copy-target="wc-agentic-commerce-ucp-url"><?php esc_html_e( 'Copy', 'woocommerce' ); ?></button>
+ </div>
+ <div class="wc-agentic-commerce-placeholder">
+ <h3><?php esc_html_e( 'Test your setup', 'woocommerce' ); ?></h3>
+ <p><?php esc_html_e( 'Coming in a WooCommerce update.', 'woocommerce' ); ?></p>
+ </div>
+ </div>
+ <?php
+ }
+
+ /**
+ * Security (WOOAI-1620, not built yet). Keys are set up automatically, so there is nothing to do here today.
+ */
+ private function render_security(): void {
+ ?>
+ <div class="wc-agentic-commerce-card wc-agentic-commerce-placeholder">
+ <div class="wc-agentic-commerce-card__header">
+ <h2><?php esc_html_e( 'Security', 'woocommerce' ); ?></h2>
+ <p><?php esc_html_e( 'Set up automatically. Nothing to do here.', 'woocommerce' ); ?></p>
+ </div>
+ <p class="wc-agentic-commerce-card__footer"><?php esc_html_e( 'Signing key details are coming in a WooCommerce update.', 'woocommerce' ); ?></p>
+ </div>
+ <?php
+ }
+
+ /**
+ * Status badge text, by UcpSwitch status.
+ *
+ * @return array<string, string>
+ */
+ private static function status_labels(): array {
+ return array(
+ UcpSwitch::STATUS_ACTIVE => __( 'Active', 'woocommerce' ),
+ UcpSwitch::STATUS_OFF => __( 'Off', 'woocommerce' ),
+ UcpSwitch::STATUS_NEEDS_ATTENTION => __( 'Action needed', 'woocommerce' ),
+ );
+ }
+
+ /**
+ * Product type slugs agents don't support yet, for the product list filter.
+ *
+ * @return array<int, string>
+ */
+ private function unsupported_product_types(): array {
+ $slugs = get_terms(
+ array(
+ 'taxonomy' => 'product_type',
+ 'hide_empty' => false,
+ 'fields' => 'slugs',
+ )
+ );
+ if ( ! is_array( $slugs ) ) {
+ return array( ProductType::GROUPED, ProductType::EXTERNAL );
+ }
+ return array_values( array_diff( array_map( 'strval', $slugs ), array( ProductType::SIMPLE, ProductType::VARIABLE ) ) );
+ }
+
+ /**
+ * Load the switch and copy button script.
+ *
+ * @param int $failing_required Number of failing required setup checks, for the message shown after turning UCP on.
+ */
+ private function enqueue_script( int $failing_required ): void {
+ wp_enqueue_script(
+ self::SCRIPT_HANDLE,
+ plugins_url( 'assets/js/admin/wc-agentic-commerce-settings' . $this->get_script_suffix() . '.js', WC_PLUGIN_FILE ),
+ array( 'wp-api-fetch' ),
+ WC_VERSION,
+ true
+ );
+ wp_localize_script(
+ self::SCRIPT_HANDLE,
+ 'wcAgenticCommerceSettings',
+ array(
+ 'path' => '/wc/v3/agent-readiness/ucp',
+ 'i18n' => array(
+ 'on' => self::on_message( $failing_required ),
+ 'error' => __( 'The setting could not be changed. Reload the page and try again.', 'woocommerce' ),
+ 'copied' => __( 'Copied', 'woocommerce' ),
+ 'statusLabel' => self::status_labels(),
+ ),
+ )
+ );
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/README.md b/plugins/woocommerce/src/Internal/AgentReadiness/README.md
index e357933d2b9..344412bdba2 100644
--- a/plugins/woocommerce/src/Internal/AgentReadiness/README.md
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/README.md
@@ -49,7 +49,7 @@ Only published simple and variable products are checked, the types agents suppor
- Products hidden from the shop catalog (catalog visibility "Search results only" or "Hidden"). Agents see the products shoppers see in the shop, so hiding one is the merchant's choice, not a problem.
- Products of any other type: grouped, external, subscription, booking, bundle and so on. A product keeps its stored type when its plugin is inactive, so it stays in this group.
-Store-wide conditions (`StoreConditions`) are reported first because they affect every product: coming soon mode and "Discourage search engines" hide the products, UCP off and Plain permalinks (no `/.well-known/ucp`) hide the catalog from UCP agents, and guest checkout off stops agents placing orders. Each condition's `blocks` says which, so a reader can tell a hidden store from one agents can't buy from.
+Store-wide conditions (`StoreConditions`) are reported first because they affect every product: coming soon mode and "Discourage search engines" hide the products, UCP off and no HTTPS hide the catalog from UCP agents, and guest checkout off stops agents placing orders. Missing policy pages and order emails off weaken agent orders. Each condition's `blocks` says which, so a reader can tell a hidden store from one agents can't buy from.
## Report format
@@ -60,10 +60,10 @@ array(
'generated_at' => '2026-10-28T12:00:00+00:00',
'products' => 120, // Published simple and variable products checked.
'hidden_from_catalog' => 4, // Not checked: catalog visibility "Search results only" or "Hidden", the merchant's choice.
- 'unsupported_types' => 6, // Not checked: grouped, external, subscription, bundle and other types agents don't support yet.
+ '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).
- array( 'id' => 'launched', 'ok' => true, 'blocks' => 'listing', 'label' => '…', 'why' => '…', 'fix' => '…' ),
+ array( 'id' => 'launched', 'ok' => true, 'blocks' => 'listing', 'level' => 'required', 'name' => '…', 'label' => '…', 'why' => '…', 'fix' => '…', 'fix_url' => '…' ),
),
'channels' => array(
'chatgpt' => array(
@@ -118,6 +118,19 @@ The same shape is meant for every consumer: this Status tab, an outside scan of
The scan walks published simple and variable products shown in the shop catalog in batches of 200, most recently modified first, primes meta, terms, images and variations per batch, and stops at 5,000 products.
+## Agentic commerce settings page
+
+WooCommerce > Settings > Agentic commerce (`AgenticCommerceSettings`, `AgenticCommerceSettingsPage`) shows while the feature is enabled. It follows the WOOPRD-3796 design, one card per part:
+
+- The UCP switch, first. It saves through `POST wc/v3/agent-readiness/ucp` (`{ "enabled": true }`). A failing required check keeps it from being turned on, and that POST answers 409; turning it off still works. In coming soon mode the card says agents can't shop the store yet and links to Site visibility.
+- Product counts from the stored report (`products`, `hidden_from_catalog`, `unsupported_types`), each linking to a filtered product list.
+- Status: the store conditions from `StoreConditions`, the same list the readiness report shows, in three groups. Required lists failing `required` conditions (coming soon mode and HTTPS) and puts the status at Action needed; Recommended lists failing `recommended` ones, such as a missing privacy, terms, refund and returns, or shipping policy page (policy pages are not required by the UCP spec); Done lists the passing ones by their passing state (`name`). Each open condition says what to do and links to it; a chosen policy page that isn't published links to its editor.
+- Store policy pages. Refund and returns, privacy and terms are the existing options, so they stay in sync with Advanced > Page setup and Settings > Privacy; shipping policy, FAQ and warranty are new. They are for the policy links a UCP checkout can carry (`links`); the UCP endpoints don't send them yet. The dropdowns list published pages other than the cart, checkout, shop and My account pages, and saving leaves a stored page in place even when it's a draft or one of those store pages, which then fails its check.
+- The store address agents start from (`/.well-known/ucp`), with Copy.
+- Placeholders for parts that aren't built yet: Test your setup (WOOAI-1619) and Security (WOOAI-1620).
+
+Tracks event: `agentic_commerce_ucp_toggled` (`enabled`), sent only when the store allows tracking.
+
## Updating rules
The per-channel field and effect live in each check's `channels()`, and that is what runs until a rules file is stored. After that, `rules.json` is the list. The `woocommerce_agent_readiness_rules` filter still runs last; unknown channels and effects in what it returns are dropped.
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/ReadinessRestController.php b/plugins/woocommerce/src/Internal/AgentReadiness/ReadinessRestController.php
index e3c950715bc..f7ff1f79a4e 100644
--- a/plugins/woocommerce/src/Internal/AgentReadiness/ReadinessRestController.php
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/ReadinessRestController.php
@@ -13,7 +13,9 @@ defined( 'ABSPATH' ) || exit;
* GET wc/v3/agent-readiness returns the stored report; POST
* wc/v3/agent-readiness/scan queues a new scan. Both answer with the report
* (null before the first scan) and whether a scan is pending, so a settings
- * page can show the last result and poll while a scan runs.
+ * page can show the last result and poll while a scan runs. POST
+ * wc/v3/agent-readiness/ucp turns UCP on or off for the Agentic commerce
+ * settings page.
*
* Registered only while the agent_readiness feature is enabled.
*
@@ -42,6 +44,13 @@ class ReadinessRestController extends RestApiControllerBase {
*/
private ScheduledScan $scheduled_scan;
+ /**
+ * UCP switch.
+ *
+ * @var UcpSwitch
+ */
+ private UcpSwitch $ucp_switch;
+
/**
* Initialize dependencies.
*
@@ -50,11 +59,13 @@ class ReadinessRestController extends RestApiControllerBase {
* @param FeaturesController $features_controller Features controller.
* @param AgentReadiness $agent_readiness Readiness service.
* @param ScheduledScan $scheduled_scan Background scan.
+ * @param UcpSwitch $ucp_switch UCP switch.
*/
- final public function init( FeaturesController $features_controller, AgentReadiness $agent_readiness, ScheduledScan $scheduled_scan ): void {
+ final public function init( FeaturesController $features_controller, AgentReadiness $agent_readiness, ScheduledScan $scheduled_scan, UcpSwitch $ucp_switch ): void {
$this->features_controller = $features_controller;
$this->agent_readiness = $agent_readiness;
$this->scheduled_scan = $scheduled_scan;
+ $this->ucp_switch = $ucp_switch;
}
/**
@@ -95,6 +106,24 @@ class ReadinessRestController extends RestApiControllerBase {
'schema' => array( $this, 'get_status_schema' ),
)
);
+ register_rest_route(
+ $this->route_namespace,
+ '/agent-readiness/ucp',
+ array(
+ array(
+ 'methods' => \WP_REST_Server::CREATABLE,
+ 'callback' => fn( $request ) => $this->run( $request, 'set_ucp_enabled' ),
+ 'permission_callback' => fn( $request ) => $this->check_permission( $request, 'manage_woocommerce' ),
+ 'args' => array(
+ 'enabled' => array(
+ 'description' => __( 'Whether AI agents can shop the store through UCP.', 'woocommerce' ),
+ 'type' => 'boolean',
+ 'required' => true,
+ ),
+ ),
+ ),
+ )
+ );
}
/**
@@ -125,6 +154,36 @@ class ReadinessRestController extends RestApiControllerBase {
return new \WP_REST_Response( $this->get_status( $request ), 202 );
}
+ /**
+ * Turn UCP on or off. A failing required check keeps it from being turned on, and that request answers 409.
+ *
+ * @param WP_REST_Request $request Request.
+ * @phpstan-param WP_REST_Request<array<string, mixed>> $request
+ * @return array{enabled:bool,status:string,failing_checks:array<int,string>}|\WP_Error
+ */
+ protected function set_ucp_enabled( WP_REST_Request $request ) {
+ $enable = (bool) $request->get_param( 'enabled' );
+ $result = $this->ucp_switch->set_enabled( $enable );
+ if ( $enable && ! $result['enabled'] && array() === $result['failing_checks'] ) {
+ return new \WP_Error(
+ 'woocommerce_ucp_not_changed',
+ __( 'UCP could not be turned on. Try again.', 'woocommerce' ),
+ array( 'status' => 500 )
+ );
+ }
+ if ( $enable && ! $result['enabled'] ) {
+ return new \WP_Error(
+ 'woocommerce_ucp_required_checks_failing',
+ __( 'Finish the required setup checks before letting agents shop your store.', 'woocommerce' ),
+ array(
+ 'status' => 409,
+ 'failing_checks' => $result['failing_checks'],
+ )
+ );
+ }
+ return $result;
+ }
+
/**
* Response schema. The report's shape is documented in this directory's README.
*
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/StoreConditions.php b/plugins/woocommerce/src/Internal/AgentReadiness/StoreConditions.php
index 2a293b1fa8f..d13554743a4 100644
--- a/plugins/woocommerce/src/Internal/AgentReadiness/StoreConditions.php
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/StoreConditions.php
@@ -10,15 +10,24 @@ defined( 'ABSPATH' ) || exit;
/**
* Store settings that stop agents finding or buying the store's products,
- * whatever the product data. `ok` says whether the store is clear of the
- * problem the label names. `blocks` says what a failing condition stops:
- * `listing` hides every product on every channel, `ucp` hides the catalog
- * from agents that use UCP, `checkout` stops agents placing orders.
+ * whatever the product data. The readiness report and the Agentic commerce
+ * settings page both read this list, so they never disagree.
+ *
+ * `ok` says whether the store is clear of the problem the label names;
+ * `name` is the check worded as its passing state.
+ * `blocks` says what a failing condition stops: `listing` hides every product
+ * on every channel, `ucp` hides the catalog from agents that use UCP,
+ * `checkout` stops or weakens agent orders. A failing `required` condition
+ * keeps UCP from being turned on; a failing `recommended` one is a warning.
*
* @since 11.4.0
*/
class StoreConditions {
+ public const LEVEL_REQUIRED = 'required';
+
+ public const LEVEL_RECOMMENDED = 'recommended';
+
/**
* Coming soon mode helper.
*
@@ -40,52 +49,157 @@ class StoreConditions {
/**
* Every condition with its current state.
*
- * @return array<int, array{id:string,ok:bool,blocks:string,label:string,why:string,fix:string}>
+ * @return array<int, array{id:string,ok:bool,blocks:string,level:string,name:string,label:string,why:string,fix:string,fix_url:string}>
*/
public function all(): array {
- global $wp_rewrite;
+ $settings_url = static fn( string $tab ): string => admin_url( 'admin.php?page=wc-settings&tab=' . $tab );
+ $policy_page = static fn( string $id, string $option, string $name, string $label, string $why ): array => array(
+ 'id' => $id,
+ 'ok' => self::is_policy_page( (int) get_option( $option ) ),
+ 'blocks' => 'checkout',
+ 'level' => self::LEVEL_RECOMMENDED,
+ 'name' => $name,
+ 'label' => $label,
+ 'why' => $why,
+ 'fix' => __( 'Choose a published page under WooCommerce > Settings > Agentic commerce.', 'woocommerce' ),
+ 'fix_url' => $settings_url( AgenticCommerceSettings::TAB ) . '#' . $option,
+ );
return array(
array(
- 'id' => 'launched',
- 'ok' => $this->coming_soon_helper->is_site_live(),
- 'blocks' => 'listing',
- 'label' => __( 'Store is in coming soon mode', 'woocommerce' ),
- 'why' => __( 'Visitors, crawlers and shopping agents see a holding page instead of your products.', 'woocommerce' ),
- 'fix' => __( 'Launch the store under WooCommerce > Settings > Site visibility.', 'woocommerce' ),
+ 'id' => 'launched',
+ 'ok' => $this->coming_soon_helper->is_site_live(),
+ 'blocks' => 'listing',
+ 'level' => self::LEVEL_REQUIRED,
+ 'name' => __( 'Store is launched', 'woocommerce' ),
+ 'label' => __( 'Store is in coming soon mode', 'woocommerce' ),
+ 'why' => __( 'Visitors, crawlers and shopping agents see a holding page instead of your products.', 'woocommerce' ),
+ 'fix' => __( 'Launch the store under WooCommerce > Settings > Site visibility.', 'woocommerce' ),
+ 'fix_url' => $settings_url( 'site-visibility' ),
),
array(
- 'id' => 'search_visible',
- 'ok' => '0' !== (string) get_option( 'blog_public', '1' ),
- 'blocks' => 'listing',
- 'label' => __( 'Store is hidden from search engines', 'woocommerce' ),
- 'why' => __( '"Discourage search engines" asks crawlers, including AI shopping crawlers, to skip your store.', 'woocommerce' ),
- 'fix' => __( 'Clear "Discourage search engines from indexing this site" under Settings > Reading.', 'woocommerce' ),
+ 'id' => 'search_visible',
+ 'ok' => '0' !== (string) get_option( 'blog_public', '1' ),
+ 'blocks' => 'listing',
+ 'level' => self::LEVEL_RECOMMENDED,
+ 'name' => __( 'Search engines can index the store', 'woocommerce' ),
+ 'label' => __( 'Store is hidden from search engines', 'woocommerce' ),
+ 'why' => __( '"Discourage search engines" asks crawlers, including AI shopping crawlers, to skip your store.', 'woocommerce' ),
+ 'fix' => __( 'Clear "Discourage search engines from indexing this site" under Settings > Reading.', 'woocommerce' ),
+ 'fix_url' => admin_url( 'options-reading.php' ),
),
array(
- 'id' => 'ucp_on',
- 'ok' => Utils::is_enabled(),
- 'blocks' => 'ucp',
- 'label' => __( 'Universal Commerce Protocol (UCP) is off', 'woocommerce' ),
- 'why' => __( 'Shopping agents that use UCP cannot read your catalog.', 'woocommerce' ),
- 'fix' => __( 'Turn on agentic commerce in the extension that connects your store to AI agents.', 'woocommerce' ),
+ 'id' => 'ucp_on',
+ 'ok' => Utils::is_enabled(),
+ 'blocks' => 'ucp',
+ 'level' => self::LEVEL_RECOMMENDED,
+ 'name' => __( 'Universal Commerce Protocol (UCP) is on', 'woocommerce' ),
+ 'label' => __( 'Universal Commerce Protocol (UCP) is off', 'woocommerce' ),
+ 'why' => __( 'Shopping agents that use UCP cannot read your catalog.', 'woocommerce' ),
+ 'fix' => __( 'Turn on "Let AI agents shop your store" under WooCommerce > Settings > Agentic commerce.', 'woocommerce' ),
+ 'fix_url' => $settings_url( AgenticCommerceSettings::TAB ),
),
array(
- 'id' => 'pretty_permalinks',
- 'ok' => $wp_rewrite->using_permalinks(),
- 'blocks' => 'ucp',
- 'label' => __( 'Permalinks are set to Plain', 'woocommerce' ),
- 'why' => __( 'Your store does not serve the UCP discovery file at /.well-known/ucp, so agents cannot find your catalog and checkout.', 'woocommerce' ),
- 'fix' => __( 'Choose any permalink structure other than Plain under Settings > Permalinks.', 'woocommerce' ),
+ 'id' => 'https',
+ 'ok' => wc_site_is_https(),
+ 'blocks' => 'ucp',
+ 'level' => self::LEVEL_REQUIRED,
+ 'name' => __( 'Site uses HTTPS', 'woocommerce' ),
+ 'label' => __( 'Site does not use HTTPS', 'woocommerce' ),
+ 'why' => __( 'Agents only send shoppers to a secure checkout.', 'woocommerce' ),
+ 'fix' => __( 'Move the site to HTTPS; your host can usually turn it on.', 'woocommerce' ),
+ 'fix_url' => wp_get_update_https_url(),
+ ),
+ $policy_page(
+ 'privacy_page',
+ AgenticCommerceSettings::PRIVACY_PAGE_OPTION,
+ __( 'Privacy policy page is selected', 'woocommerce' ),
+ __( 'No privacy policy page', 'woocommerce' ),
+ __( 'Agents will be able to link shoppers to your privacy policy before they buy.', 'woocommerce' )
+ ),
+ $policy_page(
+ 'terms_page',
+ AgenticCommerceSettings::TERMS_PAGE_OPTION,
+ __( 'Terms and conditions page is selected', 'woocommerce' ),
+ __( 'No terms and conditions page', 'woocommerce' ),
+ __( 'Agents will be able to link shoppers to your terms before they buy.', 'woocommerce' )
+ ),
+ $policy_page(
+ 'refund_returns_page',
+ AgenticCommerceSettings::REFUND_RETURNS_PAGE_OPTION,
+ __( 'Refund and returns page is selected', 'woocommerce' ),
+ __( 'No refund and returns page', 'woocommerce' ),
+ __( 'Agents will be able to link shoppers to your return terms before they buy.', 'woocommerce' )
+ ),
+ $policy_page(
+ 'shipping_policy_page',
+ AgenticCommerceSettings::SHIPPING_POLICY_PAGE_OPTION,
+ __( 'Shipping policy page is selected', 'woocommerce' ),
+ __( 'No shipping policy page', 'woocommerce' ),
+ __( 'Shoppers ask agents about delivery times and costs.', 'woocommerce' )
),
array(
- 'id' => 'guest_checkout',
- 'ok' => ! $this->registration_required(),
- 'blocks' => 'checkout',
- 'label' => __( 'Guest checkout is off', 'woocommerce' ),
- 'why' => __( 'Shopping agents check out for shoppers without an account on your store, so they cannot place orders.', 'woocommerce' ),
- 'fix' => __( 'Tick "Enable guest checkout" under WooCommerce > Settings > Accounts & Privacy.', 'woocommerce' ),
+ 'id' => 'guest_checkout',
+ 'ok' => ! $this->registration_required(),
+ 'blocks' => 'checkout',
+ 'level' => self::LEVEL_RECOMMENDED,
+ 'name' => __( 'Guest checkout is allowed', 'woocommerce' ),
+ 'label' => __( 'Guest checkout is off', 'woocommerce' ),
+ 'why' => __( 'Shopping agents check out for shoppers without an account on your store, so they cannot place orders.', 'woocommerce' ),
+ 'fix' => __( 'Tick "Enable guest checkout" under WooCommerce > Settings > Accounts & Privacy.', 'woocommerce' ),
+ 'fix_url' => $settings_url( 'account' ),
),
+ array(
+ 'id' => 'order_emails',
+ 'ok' => $this->order_confirmation_email_enabled(),
+ 'blocks' => 'checkout',
+ 'level' => self::LEVEL_RECOMMENDED,
+ 'name' => __( 'Order confirmation emails are on', 'woocommerce' ),
+ 'label' => __( 'Order confirmation emails are off', 'woocommerce' ),
+ 'why' => __( 'Shoppers who buy through an agent get no confirmation from your store.', 'woocommerce' ),
+ 'fix' => __( 'Turn on the Processing or Completed order email under WooCommerce > Settings > Emails.', 'woocommerce' ),
+ 'fix_url' => $settings_url( 'email§ion=wc_email_customer_processing_order' ),
+ ),
+ );
+ }
+
+ /**
+ * Ids of the required conditions that fail.
+ *
+ * @param array<int, array{id:string,ok:bool,level:string}>|null $conditions Conditions from all(), or null to check them now.
+ * @return array<int, string>
+ */
+ public function failing_required( ?array $conditions = null ): array {
+ $ids = array();
+ foreach ( $conditions ?? $this->all() as $condition ) {
+ if ( self::LEVEL_REQUIRED === $condition['level'] && ! $condition['ok'] ) {
+ $ids[] = $condition['id'];
+ }
+ }
+ return $ids;
+ }
+
+ /**
+ * Whether a page id is a published page other than the cart, checkout, shop or My account page.
+ *
+ * @param int $page_id Page id.
+ */
+ public static function is_policy_page( int $page_id ): bool {
+ return $page_id > 0 && 'page' === get_post_type( $page_id ) && 'publish' === get_post_status( $page_id ) && ! in_array( $page_id, self::store_page_ids(), true );
+ }
+
+ /**
+ * The cart, checkout, shop and My account pages, which can't be a policy page.
+ * Advanced > Page setup leaves them out of its terms and refund fields too.
+ *
+ * @return array<int, int>
+ */
+ public static function store_page_ids(): array {
+ return array_values(
+ array_filter(
+ array_map( 'wc_get_page_id', array( 'cart', 'checkout', 'shop', 'myaccount' ) ),
+ static fn ( $page_id ) => $page_id > 0
+ )
);
}
@@ -97,4 +211,19 @@ class StoreConditions {
// phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment -- Core's hook, documented in WC_Checkout.
return (bool) apply_filters( 'woocommerce_checkout_registration_required', 'yes' !== get_option( 'woocommerce_enable_guest_checkout' ) );
}
+
+ /**
+ * Whether a shopper gets an order email after checkout. Virtual orders skip
+ * processing and are confirmed by the Completed order email instead.
+ */
+ private function order_confirmation_email_enabled(): bool {
+ $emails = WC()->mailer()->get_emails();
+ foreach ( array( 'WC_Email_Customer_Processing_Order', 'WC_Email_Customer_Completed_Order' ) as $id ) {
+ $email = $emails[ $id ] ?? null;
+ if ( $email instanceof \WC_Email && $email->is_enabled() ) {
+ return true;
+ }
+ }
+ return false;
+ }
}
diff --git a/plugins/woocommerce/src/Internal/AgentReadiness/UcpSwitch.php b/plugins/woocommerce/src/Internal/AgentReadiness/UcpSwitch.php
new file mode 100644
index 00000000000..4fe4560e024
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/AgentReadiness/UcpSwitch.php
@@ -0,0 +1,95 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\AgentReadiness;
+
+use Automattic\WooCommerce\Internal\Features\FeaturesController;
+use Automattic\WooCommerce\StoreApi\Utilities\Ucp\Utils as UcpUtils;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Turns UCP on or off from the Agentic commerce settings page. A failing
+ * required setup check keeps it from being turned on. Turning it off still
+ * works, and a switch that is already on shows as Needs attention.
+ *
+ * @since 11.4.0
+ */
+class UcpSwitch {
+
+ public const STATUS_ACTIVE = 'active';
+
+ public const STATUS_OFF = 'off';
+
+ public const STATUS_NEEDS_ATTENTION = 'needs_attention';
+
+ /**
+ * Features controller.
+ *
+ * @var FeaturesController
+ */
+ private FeaturesController $features_controller;
+
+ /**
+ * Store conditions.
+ *
+ * @var StoreConditions
+ */
+ private StoreConditions $store_conditions;
+
+ /**
+ * Initialize dependencies.
+ *
+ * @internal
+ *
+ * @param FeaturesController $features_controller Features controller.
+ * @param StoreConditions $store_conditions Store conditions.
+ */
+ final public function init( FeaturesController $features_controller, StoreConditions $store_conditions ): void {
+ $this->features_controller = $features_controller;
+ $this->store_conditions = $store_conditions;
+ }
+
+ /**
+ * Turn UCP on or off.
+ *
+ * @param bool $enable Whether to turn UCP on.
+ * @return array{enabled:bool,status:string,failing_checks:array<int,string>}
+ */
+ public function set_enabled( bool $enable ): array {
+ $failing = $this->store_conditions->failing_required();
+
+ // Already on stays on, so it can be turned off. Off stays off until the required checks pass.
+ if ( $enable && ! UcpUtils::is_enabled() && array() !== $failing ) {
+ return array(
+ 'enabled' => false,
+ 'status' => self::STATUS_OFF,
+ 'failing_checks' => $failing,
+ );
+ }
+
+ if ( UcpUtils::is_enabled() !== $enable ) {
+ $this->features_controller->change_feature_enable( UcpUtils::FEATURE_ID, $enable );
+ \WC_Tracks::record_event( 'agentic_commerce_ucp_toggled', array( 'enabled' => $enable ? 'yes' : 'no' ) );
+ }
+
+ return array(
+ 'enabled' => UcpUtils::is_enabled(),
+ 'status' => $this->status( $failing ),
+ 'failing_checks' => $failing,
+ );
+ }
+
+ /**
+ * Active, off, or needs attention when UCP is on but a required check fails.
+ *
+ * @param array<int, string>|null $failing Failing required check ids, or null to run the checks.
+ */
+ public function status( ?array $failing = null ): string {
+ if ( ! UcpUtils::is_enabled() ) {
+ return self::STATUS_OFF;
+ }
+ $failing = $failing ?? $this->store_conditions->failing_required();
+ return empty( $failing ) ? self::STATUS_ACTIVE : self::STATUS_NEEDS_ATTENTION;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Features/FeaturesController.php b/plugins/woocommerce/src/Internal/Features/FeaturesController.php
index d2f470254a8..443a02f69d6 100644
--- a/plugins/woocommerce/src/Internal/Features/FeaturesController.php
+++ b/plugins/woocommerce/src/Internal/Features/FeaturesController.php
@@ -665,7 +665,7 @@ class FeaturesController {
'agent_readiness' => array(
'name' => __( 'Agent readiness', 'woocommerce' ),
'description' => __(
- 'Check products against the product data specs of AI shopping channels under WooCommerce > Status.',
+ 'Check products against the product data specs of AI shopping channels under WooCommerce > Status, and set up agentic commerce under WooCommerce > Settings.',
'woocommerce'
),
'enabled_by_default' => false,
diff --git a/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/AgentReadinessTest.php b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/AgentReadinessTest.php
index 248d2e4fbaf..46bed6d6128 100644
--- a/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/AgentReadinessTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/AgentReadinessTest.php
@@ -44,7 +44,7 @@ class AgentReadinessTest extends WC_Unit_Test_Case {
$this->assertSame( array( 'generated_at', 'products', 'hidden_from_catalog', 'unsupported_types', 'store_checks', 'channels', 'checks', 'truncated' ), array_keys( $report ) );
$this->assertFalse( $report['truncated'] );
- $this->assertSame( array( 'id', 'ok', 'blocks', 'label', 'why', 'fix' ), array_keys( $report['store_checks'][0] ) );
+ $this->assertSame( array( 'id', 'ok', 'blocks', 'level', 'name', 'label', 'why', 'fix', 'fix_url' ), array_keys( $report['store_checks'][0] ) );
$this->assertSame( array( 'chatgpt', 'google', 'copilot' ), array_keys( $report['channels'] ) );
$this->assertSame(
array( 'name', 'spec', 'url', 'listable', 'not_listable', 'with_gaps', 'complete' ),
@@ -96,6 +96,7 @@ class AgentReadinessTest extends WC_Unit_Test_Case {
$this->assertArrayNotHasKey( 'missing_brand', $checks );
$this->assertSame( ReadinessRule::EFFECT_NOT_LISTABLE, $checks['missing_gtin']['channels']['google']['effect'] );
$this->assertArrayNotHasKey( 'bing', $checks['missing_gtin']['channels'] );
+ $this->assertSame( array( 'chatgpt', 'copilot', 'google' ), array_values( array_intersect( array( 'chatgpt', 'copilot', 'google' ), array_keys( $checks['missing_gtin']['channels'] ) ) ), 'Channels the filter left alone are kept' );
$this->assertArrayNotHasKey( 'google', $checks['title_too_long']['channels'] );
$this->assertArrayNotHasKey( 'uncategorised', $checks, 'A check with no valid channel left does not run' );
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/AgenticCommerceSettingsTest.php b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/AgenticCommerceSettingsTest.php
new file mode 100644
index 00000000000..18a1a5d5053
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/AgenticCommerceSettingsTest.php
@@ -0,0 +1,405 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\AgentReadiness;
+
+use Automattic\WooCommerce\Internal\AgentReadiness\AgenticCommerceSettings;
+use Automattic\WooCommerce\Internal\AgentReadiness\AgenticCommerceSettingsPage;
+use Automattic\WooCommerce\Internal\AgentReadiness\AgentReadiness;
+use WC_Admin_Settings;
+use WC_Helper_Product;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the Agentic commerce settings tab.
+ */
+class AgenticCommerceSettingsTest extends WC_Unit_Test_Case {
+
+ /**
+ * The System Under Test.
+ *
+ * @var AgenticCommerceSettings
+ */
+ private $sut;
+
+ /**
+ * Load the settings page base class and act as an admin in wp-admin.
+ */
+ public function setUp(): void {
+ parent::setUp();
+ include_once WC_ABSPATH . 'includes/admin/class-wc-admin-settings.php';
+ include_once WC_ABSPATH . 'includes/admin/settings/class-wc-settings-page.php';
+ set_current_screen( 'dashboard' );
+ wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
+ update_option( 'woocommerce_feature_agent_readiness_enabled', 'yes' );
+ update_option( 'woocommerce_feature_ucp_enabled', 'no' );
+ $this->sut = wc_get_container()->get( AgenticCommerceSettings::class );
+ }
+
+ /**
+ * Leave the admin screen, which is a global outside the rollback.
+ */
+ public function tearDown(): void {
+ try {
+ set_current_screen( 'front' );
+ } finally {
+ parent::tearDown();
+ }
+ }
+
+ /**
+ * @testdox The tab is added only while the agent_readiness feature is enabled.
+ */
+ public function test_tab_only_with_feature_enabled(): void {
+ update_option( 'woocommerce_feature_agent_readiness_enabled', 'no' );
+ remove_all_filters( 'woocommerce_get_settings_pages' );
+ $this->sut->handle_init();
+ $this->assertFalse( has_filter( 'woocommerce_get_settings_pages' ), 'No tab with the feature off' );
+
+ update_option( 'woocommerce_feature_agent_readiness_enabled', 'yes' );
+ update_option( 'woocommerce_feature_ucp_enabled', 'no' );
+ $this->sut->handle_init();
+ $pages = apply_filters( 'woocommerce_get_settings_pages', array() );
+
+ $this->assertCount( 1, $pages );
+ $this->assertInstanceOf( AgenticCommerceSettingsPage::class, $pages[0] );
+ $this->assertSame( 'agentic-commerce', $pages[0]->get_id() );
+ }
+
+ /**
+ * @testdox Before the first scan the page asks for one instead of showing counts; dropdowns list published pages only.
+ */
+ public function test_output_without_report(): void {
+ delete_option( AgentReadiness::REPORT_OPTION );
+ self::factory()->post->create(
+ array(
+ 'post_type' => 'page',
+ 'post_status' => 'publish',
+ 'post_title' => 'Published returns page',
+ )
+ );
+ self::factory()->post->create(
+ array(
+ 'post_type' => 'page',
+ 'post_status' => 'draft',
+ 'post_title' => 'Draft returns page',
+ )
+ );
+
+ $html = $this->render_page();
+
+ $this->assertStringContainsString( 'Product counts appear after the first agent readiness check.', $html );
+ $this->assertStringNotContainsString( 'View products', $html );
+ $this->assertStringContainsString( 'Published returns page', $html );
+ $this->assertStringNotContainsString( 'Draft returns page', $html );
+ }
+
+ /**
+ * @testdox The page shows the three counts from the stored report, the switch and failing checks.
+ */
+ public function test_output_with_report_and_failing_checks(): void {
+ remove_all_filters( 'pre_option_home' );
+ update_option( 'woocommerce_coming_soon', 'yes' );
+ update_option( 'home', 'http://example.org' );
+ WC_Helper_Product::create_simple_product();
+ WC_Helper_Product::create_external_product();
+ wc_get_container()->get( AgentReadiness::class )->refresh_report();
+
+ $html = $this->render_page();
+
+ $this->assertStringContainsString( '<span>Available to agents</span> <strong>1</strong>', $html );
+ $this->assertStringContainsString( '<span>Not supported yet</span> <strong>1</strong>', $html );
+ $this->assertStringContainsString( 'Simple and variable products only, for now.', $html );
+ $this->assertStringNotContainsString( 'aren't shown', $html );
+ $this->assertStringContainsString( '<span>Available to agents</span> <strong>1</strong></li>', $html, 'No list matches the available count, so it has no link' );
+ $this->assertStringContainsString( 'product_type=simple,variable&product_visibility=exclude-from-catalog', $html, 'The hidden link matches its count' );
+ $this->assertStringContainsString( 'aria-checked="false"', $html );
+ $this->assertStringNotContainsString( 'to turn this on', $html );
+ $this->assertMatchesRegularExpression( '/but \d+ required setup checks? below still needs? fixing\./', (string) wp_scripts()->get_data( 'wc-agentic-commerce-settings', 'data' ), 'Turning the switch on shows the failing checks without a reload' );
+ $this->assertStringContainsString( 'wc-agentic-commerce-check--required', $html, 'Failing required checks are in the Required group' );
+ $this->assertMatchesRegularExpression( '/(Choose a|Publish your) privacy policy page/', $html, 'An open check says what to do' );
+ $this->assertStringContainsString( 'Test your setup', $html, 'Placeholder until WOOAI-1619' );
+ $this->assertStringContainsString( '<h2>Security</h2>', $html, 'Placeholder until WOOAI-1620' );
+ $this->assertStringNotContainsString( 'Universal Commerce Protocol (UCP) is off', $html, 'UCP is the switch, not a listed check' );
+ }
+
+ /**
+ * @testdox The switch message counts failing required checks, singular for one, and says ready for none.
+ */
+ public function test_on_message_counts(): void {
+ $on_message = new \ReflectionMethod( AgenticCommerceSettingsPage::class, 'on_message' );
+ $on_message->setAccessible( true );
+
+ $this->assertStringContainsString( 'but 1 required setup check below still needs fixing.', $on_message->invoke( null, 1 ) );
+ $this->assertStringContainsString( 'but 3 required setup checks below still need fixing.', $on_message->invoke( null, 3 ) );
+ $this->assertSame( 'AI agents can now find and shop your store.', $on_message->invoke( null, 0 ) );
+ }
+
+ /**
+ * @testdox With every required check passing, open checks are only Recommended and passing ones are Done.
+ */
+ public function test_output_on_a_ready_store(): void {
+ StoreConditionsTest::make_store_ready();
+
+ $html = $this->render_page();
+
+ $this->assertStringContainsString( 'Everything agents need is set up.', $html );
+ $this->assertStringNotContainsString( 'wc-agentic-commerce-check--required', $html );
+ $this->assertStringContainsString( 'wc-agentic-commerce-group--done', $html );
+ $this->assertStringContainsString( 'Privacy policy page is selected', $html, 'Done rows are named by their passing state' );
+ $this->assertStringNotContainsString( 'wc-agentic-commerce-notice', $html, 'No coming soon notice on a live store' );
+ }
+
+ /**
+ * @testdox A missing policy page is recommended, so it does not block the switch or the required group.
+ */
+ public function test_missing_policy_page_is_recommended(): void {
+ StoreConditionsTest::make_store_ready();
+ delete_option( AgenticCommerceSettings::PRIVACY_PAGE_OPTION );
+
+ $html = $this->render_page();
+
+ $this->assertStringNotContainsString( 'wc-agentic-commerce-check--required', $html );
+ $this->assertMatchesRegularExpression( '/wc-agentic-commerce-group--recommended.*Choose a privacy policy page/s', $html );
+ $this->assertStringContainsString( 'Everything agents need is set up.', $html );
+ $this->assertDoesNotMatchRegularExpression( '/<button[^>]*wc-agentic-commerce-ucp-switch[^>]*disabled=([\'"])disabled\\1/', $html );
+ }
+
+ /**
+ * @testdox In coming soon mode the switch card says so and links to Site visibility.
+ */
+ public function test_output_coming_soon_notice_in_switch_card(): void {
+ update_option( 'woocommerce_coming_soon', 'yes' );
+
+ $html = $this->render_page();
+
+ $this->assertMatchesRegularExpression( '/wc-agentic-commerce-switch-card.*wc-agentic-commerce-notice.*Go to Site visibility/s', $html );
+ $this->assertLessThan( strpos( $html, 'wc-agentic-commerce-status-card' ), strpos( $html, 'wc-agentic-commerce-switch-card' ), 'The switch comes first' );
+ }
+
+ /**
+ * @testdox A chosen policy page that isn't published links to its editor.
+ */
+ public function test_draft_policy_page_links_to_review_and_publish(): void {
+ $draft = self::factory()->post->create(
+ array(
+ 'post_type' => 'page',
+ 'post_status' => 'draft',
+ )
+ );
+ update_option( AgenticCommerceSettings::REFUND_RETURNS_PAGE_OPTION, $draft );
+
+ $html = $this->render_page();
+
+ $this->assertStringContainsString( 'Publish your refund and returns policy page', $html );
+ $this->assertStringContainsString( 'Review and publish', $html );
+ }
+
+ /**
+ * @testdox With UCP already on and a required check failing, the switch says the checks still need fixing.
+ */
+ public function test_output_switch_on_with_failing_checks(): void {
+ $page = $this->page();
+ remove_all_filters( 'pre_option_home' );
+ update_option( 'woocommerce_coming_soon', 'yes' );
+ update_option( 'home', 'http://example.org' );
+ update_option( 'woocommerce_feature_ucp_enabled', 'yes' );
+
+ ob_start();
+ $page->output();
+ $html = (string) ob_get_clean();
+
+ $this->assertStringContainsString( 'aria-checked="true"', $html );
+ $this->assertStringContainsString( 'required setup checks below still need fixing.', $html );
+ $this->assertStringNotContainsString( 'AI agents can now find and shop your store.</p>', $html );
+ }
+
+ /**
+ * @testdox A stored draft page is listed in its own dropdown and kept when resubmitted, an empty choice clears a stored draft, and a new draft is not saved.
+ */
+ public function test_save_keeps_draft_page_and_syncs_shared_options(): void {
+ $draft = self::factory()->post->create(
+ array(
+ 'post_type' => 'page',
+ 'post_status' => 'draft',
+ 'post_title' => 'Returns draft',
+ )
+ );
+ $old_faq = self::factory()->post->create(
+ array(
+ 'post_type' => 'page',
+ 'post_status' => 'draft',
+ )
+ );
+ $published = self::factory()->post->create(
+ array(
+ 'post_type' => 'page',
+ 'post_status' => 'publish',
+ )
+ );
+ update_option( AgenticCommerceSettings::REFUND_RETURNS_PAGE_OPTION, $draft );
+ update_option( AgenticCommerceSettings::FAQ_PAGE_OPTION, $old_faq );
+
+ $settings = $this->page()->get_settings_for_section( '' );
+ $refund = current( wp_list_filter( $settings, array( 'id' => AgenticCommerceSettings::REFUND_RETURNS_PAGE_OPTION ) ) );
+ $terms = current( wp_list_filter( $settings, array( 'id' => AgenticCommerceSettings::TERMS_PAGE_OPTION ) ) );
+ $this->assertSame( 'Returns draft (Draft)', $refund['options'][ (string) $draft ], 'The stored draft is listed with its status' );
+ $this->assertArrayNotHasKey( (string) $draft, $terms['options'], 'Other dropdowns list published pages only' );
+
+ WC_Admin_Settings::save_fields(
+ $settings,
+ array(
+ AgenticCommerceSettings::REFUND_RETURNS_PAGE_OPTION => (string) $draft,
+ AgenticCommerceSettings::PRIVACY_PAGE_OPTION => (string) $published,
+ AgenticCommerceSettings::TERMS_PAGE_OPTION => (string) $draft,
+ AgenticCommerceSettings::SHIPPING_POLICY_PAGE_OPTION => (string) $published,
+ AgenticCommerceSettings::FAQ_PAGE_OPTION => '',
+ AgenticCommerceSettings::WARRANTY_PAGE_OPTION => (string) $published,
+ )
+ );
+
+ $this->assertSame( $draft, (int) get_option( AgenticCommerceSettings::REFUND_RETURNS_PAGE_OPTION ), 'A stored draft page is kept' );
+ $this->assertSame( $published, (int) get_option( AgenticCommerceSettings::PRIVACY_PAGE_OPTION ), 'Privacy policy is the WordPress option' );
+ $this->assertSame( '', (string) get_option( AgenticCommerceSettings::TERMS_PAGE_OPTION, '' ), 'A draft page id is not saved' );
+ $this->assertSame( $published, (int) get_option( AgenticCommerceSettings::SHIPPING_POLICY_PAGE_OPTION ) );
+ $this->assertSame( '', (string) get_option( AgenticCommerceSettings::FAQ_PAGE_OPTION ), 'An empty choice clears a stored draft' );
+ $this->assertSame( $published, (int) get_option( AgenticCommerceSettings::WARRANTY_PAGE_OPTION ) );
+ $this->assertFalse( get_option( 'woocommerce_return_policy_summary' ), 'There is no return policy summary' );
+ }
+
+ /**
+ * @testdox The cart, checkout, shop and My account pages can't be picked as a policy page.
+ */
+ public function test_store_pages_are_not_policy_pages(): void {
+ $checkout = self::factory()->post->create(
+ array(
+ 'post_type' => 'page',
+ 'post_status' => 'publish',
+ )
+ );
+ update_option( 'woocommerce_checkout_page_id', $checkout );
+ $terms_field = array();
+ foreach ( $this->page()->get_settings_for_section( '' ) as $field ) {
+ if ( AgenticCommerceSettings::TERMS_PAGE_OPTION === ( $field['id'] ?? '' ) ) {
+ $terms_field = $field;
+ }
+ }
+ $this->assertArrayNotHasKey( (string) $checkout, $terms_field['options'], 'The checkout page is not offered' );
+
+ WC_Admin_Settings::save_fields(
+ $this->page()->get_settings_for_section( '' ),
+ array( AgenticCommerceSettings::TERMS_PAGE_OPTION => (string) $checkout )
+ );
+
+ $this->assertNotSame( $checkout, (int) get_option( AgenticCommerceSettings::TERMS_PAGE_OPTION ), 'A posted checkout page id is not saved' );
+ }
+
+ /**
+ * @testdox A store page set as the terms page under Advanced > Page setup is listed, saving the tab keeps it, and Status asks for another page.
+ */
+ public function test_save_keeps_store_page_set_as_terms_page(): void {
+ $cart = self::factory()->post->create(
+ array(
+ 'post_type' => 'page',
+ 'post_status' => 'publish',
+ 'post_title' => 'Cart',
+ )
+ );
+ update_option( 'woocommerce_cart_page_id', $cart );
+ update_option( AgenticCommerceSettings::TERMS_PAGE_OPTION, $cart );
+
+ $settings = $this->page()->get_settings_for_section( '' );
+ $terms = current( wp_list_filter( $settings, array( 'id' => AgenticCommerceSettings::TERMS_PAGE_OPTION ) ) );
+ $this->assertSame( 'Cart (Store page)', $terms['options'][ (string) $cart ] );
+ $html = $this->render_page();
+ $this->assertStringContainsString( 'Choose a terms and conditions page', $html );
+ $this->assertStringNotContainsString( 'Publish your terms and conditions page', $html );
+
+ WC_Admin_Settings::save_fields( $settings, array( AgenticCommerceSettings::TERMS_PAGE_OPTION => (string) $cart ) );
+
+ $this->assertSame( $cart, (int) get_option( AgenticCommerceSettings::TERMS_PAGE_OPTION ) );
+ }
+
+ /**
+ * @testdox Clearing a published page on the tab clears it everywhere.
+ */
+ public function test_save_clears_published_page(): void {
+ $published = self::factory()->post->create(
+ array(
+ 'post_type' => 'page',
+ 'post_status' => 'publish',
+ )
+ );
+ update_option( AgenticCommerceSettings::TERMS_PAGE_OPTION, $published );
+
+ WC_Admin_Settings::save_fields(
+ $this->page()->get_settings_for_section( '' ),
+ array( AgenticCommerceSettings::TERMS_PAGE_OPTION => '' )
+ );
+
+ $this->assertSame( '', get_option( AgenticCommerceSettings::TERMS_PAGE_OPTION ) );
+ }
+
+ /**
+ * @testdox A shop manager cannot see or change the WordPress privacy policy page.
+ */
+ public function test_privacy_page_needs_manage_privacy_options(): void {
+ $privacy = self::factory()->post->create(
+ array(
+ 'post_type' => 'page',
+ 'post_status' => 'publish',
+ )
+ );
+ $published = self::factory()->post->create(
+ array(
+ 'post_type' => 'page',
+ 'post_status' => 'publish',
+ )
+ );
+ update_option( AgenticCommerceSettings::PRIVACY_PAGE_OPTION, $privacy );
+ wp_set_current_user( self::factory()->user->create( array( 'role' => 'shop_manager' ) ) );
+
+ $settings = $this->page()->get_settings_for_section( '' );
+ WC_Admin_Settings::save_fields( $settings, array( AgenticCommerceSettings::PRIVACY_PAGE_OPTION => (string) $published ) );
+
+ $this->assertNotContains( AgenticCommerceSettings::PRIVACY_PAGE_OPTION, array_column( $settings, 'id' ) );
+ $this->assertSame( $privacy, (int) get_option( AgenticCommerceSettings::PRIVACY_PAGE_OPTION ) );
+ }
+
+ /**
+ * @testdox The switch stays disabled until the required checks pass.
+ */
+ public function test_switch_disabled_until_required_checks_pass(): void {
+ update_option( 'woocommerce_coming_soon', 'yes' );
+
+ $blocked = $this->render_page();
+ $this->assertMatchesRegularExpression( '/<button[^>]*wc-agentic-commerce-ucp-switch[^>]*disabled=([\'"])disabled\\1/', $blocked );
+
+ StoreConditionsTest::make_store_ready();
+ $ready = $this->render_page();
+ $this->assertDoesNotMatchRegularExpression( '/<button[^>]*wc-agentic-commerce-ucp-switch[^>]*disabled=([\'"])disabled\\1/', $ready );
+ }
+
+ /**
+ * A fresh settings page.
+ */
+ private function page(): AgenticCommerceSettingsPage {
+ update_option( 'woocommerce_feature_agent_readiness_enabled', 'yes' );
+ update_option( 'woocommerce_feature_ucp_enabled', 'no' );
+ $this->sut->handle_init();
+ $pages = $this->sut->handle_woocommerce_get_settings_pages( array() );
+ return $pages[0];
+ }
+
+ /**
+ * Render the default section.
+ */
+ private function render_page(): string {
+ // Localized data accumulates on a registered handle, so start each render clean.
+ wp_deregister_script( 'wc-agentic-commerce-settings' );
+ $page = $this->page();
+ ob_start();
+ $page->output();
+ return (string) ob_get_clean();
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/ReadinessChecksTest.php b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/ReadinessChecksTest.php
index 3983257d1d4..b9bb683a0ce 100644
--- a/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/ReadinessChecksTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/ReadinessChecksTest.php
@@ -357,67 +357,63 @@ class ReadinessChecksTest extends WC_Unit_Test_Case {
}
/**
- * @testdox Store conditions report coming soon mode, discouraged search engines, UCP, permalinks and guest checkout.
+ * @testdox Store conditions report coming soon mode, discouraged search engines, UCP and guest checkout.
*/
public function test_store_conditions(): void {
- global $wp_rewrite;
- $permalinks = (string) get_option( 'permalink_structure' );
- try {
- $sut = new StoreConditions();
- $sut->init( new ComingSoonHelper() );
-
- update_option( 'woocommerce_coming_soon', 'no' );
- update_option( 'blog_public', '1' );
- update_option( 'woocommerce_feature_ucp_enabled', 'yes' );
- update_option( 'woocommerce_enable_guest_checkout', 'yes' );
- $wp_rewrite->set_permalink_structure( '/%postname%/' );
- $this->assertSame(
- array(
- 'launched' => true,
- 'search_visible' => true,
- 'ucp_on' => true,
- 'pretty_permalinks' => true,
- 'guest_checkout' => true,
- ),
- array_column( $sut->all(), 'ok', 'id' )
- );
+ $sut = new StoreConditions();
+ $sut->init( new ComingSoonHelper() );
+ $ok = static fn(): array => array_intersect_key( array_column( $sut->all(), 'ok', 'id' ), array_flip( array( 'launched', 'search_visible', 'ucp_on', 'guest_checkout' ) ) );
+
+ update_option( 'woocommerce_coming_soon', 'no' );
+ update_option( 'blog_public', '1' );
+ update_option( 'woocommerce_feature_ucp_enabled', 'yes' );
+ update_option( 'woocommerce_enable_guest_checkout', 'yes' );
+ $this->assertSame(
+ array(
+ 'launched' => true,
+ 'search_visible' => true,
+ 'ucp_on' => true,
+ 'guest_checkout' => true,
+ ),
+ $ok()
+ );
- update_option( 'woocommerce_coming_soon', 'yes' );
- update_option( 'woocommerce_store_pages_only', 'yes' );
- update_option( 'blog_public', '0' );
- update_option( 'woocommerce_feature_ucp_enabled', 'no' );
- update_option( 'woocommerce_enable_guest_checkout', 'no' );
- $wp_rewrite->set_permalink_structure( '' );
- $this->assertSame(
- array(
- 'launched' => false,
- 'search_visible' => false,
- 'ucp_on' => false,
- 'pretty_permalinks' => false,
- 'guest_checkout' => false,
- ),
- array_column( $sut->all(), 'ok', 'id' )
- );
+ update_option( 'woocommerce_coming_soon', 'yes' );
+ update_option( 'woocommerce_store_pages_only', 'yes' );
+ update_option( 'blog_public', '0' );
+ update_option( 'woocommerce_feature_ucp_enabled', 'no' );
+ update_option( 'woocommerce_enable_guest_checkout', 'no' );
+ $this->assertSame(
+ array(
+ 'launched' => false,
+ 'search_visible' => false,
+ 'ucp_on' => false,
+ 'guest_checkout' => false,
+ ),
+ $ok()
+ );
- update_option( 'woocommerce_enable_guest_checkout', 'yes' );
- add_filter( 'woocommerce_checkout_registration_required', '__return_true' );
- $guest_checkout = array_column( $sut->all(), 'ok', 'id' )['guest_checkout'];
- remove_filter( 'woocommerce_checkout_registration_required', '__return_true' );
- $this->assertFalse( $guest_checkout, 'An extension that requires registration should fail the check' );
+ update_option( 'woocommerce_enable_guest_checkout', 'yes' );
+ add_filter( 'woocommerce_checkout_registration_required', '__return_true' );
+ $guest_checkout = array_column( $sut->all(), 'ok', 'id' )['guest_checkout'];
+ remove_filter( 'woocommerce_checkout_registration_required', '__return_true' );
+ $this->assertFalse( $guest_checkout, 'An extension that requires registration should fail the check' );
- $this->assertSame(
- array(
- 'launched' => 'listing',
- 'search_visible' => 'listing',
- 'ucp_on' => 'ucp',
- 'pretty_permalinks' => 'ucp',
- 'guest_checkout' => 'checkout',
- ),
- array_column( $sut->all(), 'blocks', 'id' )
- );
- } finally {
- $wp_rewrite->set_permalink_structure( $permalinks );
- }
+ $this->assertSame(
+ array(
+ 'launched' => 'listing',
+ 'search_visible' => 'listing',
+ 'ucp_on' => 'ucp',
+ 'https' => 'ucp',
+ 'privacy_page' => 'checkout',
+ 'terms_page' => 'checkout',
+ 'refund_returns_page' => 'checkout',
+ 'shipping_policy_page' => 'checkout',
+ 'guest_checkout' => 'checkout',
+ 'order_emails' => 'checkout',
+ ),
+ array_column( $sut->all(), 'blocks', 'id' )
+ );
}
/**
diff --git a/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/ReadinessRestControllerTest.php b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/ReadinessRestControllerTest.php
index e760b86a23a..41b91a0baea 100644
--- a/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/ReadinessRestControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/ReadinessRestControllerTest.php
@@ -5,6 +5,7 @@ namespace Automattic\WooCommerce\Tests\Internal\AgentReadiness;
use Automattic\WooCommerce\Internal\AgentReadiness\AgentReadiness;
use Automattic\WooCommerce\Internal\AgentReadiness\ScheduledScan;
+use Automattic\WooCommerce\Internal\AgentReadiness\UcpSwitch;
use WC_REST_Unit_Test_Case;
use WP_REST_Request;
@@ -109,4 +110,89 @@ class ReadinessRestControllerTest extends WC_REST_Unit_Test_Case {
$this->assertSame( 404, $wp_rest_server->dispatch( new WP_REST_Request( 'GET', '/wc/v3/agent-readiness' ) )->get_status() );
}
+
+ /**
+ * @testdox POST ucp answers 409 and leaves UCP off while a required setup check fails.
+ */
+ public function test_ucp_switch_blocked_by_failing_check(): void {
+ StoreConditionsTest::make_store_ready();
+ update_option( 'woocommerce_coming_soon', 'yes' );
+ update_option( 'woocommerce_feature_ucp_enabled', 'no' );
+
+ $response = $this->dispatch_ucp( true );
+ $data = $response->get_data();
+
+ $this->assertSame( 409, $response->get_status() );
+ $this->assertSame( 'woocommerce_ucp_required_checks_failing', $data['code'] );
+ $this->assertSame( array( 'launched' ), $data['data']['failing_checks'] );
+ $this->assertSame( 'no', get_option( 'woocommerce_feature_ucp_enabled' ) );
+ }
+
+ /**
+ * @testdox POST ucp turns UCP on once the required checks pass, and off again.
+ */
+ public function test_ucp_switch_on_and_off(): void {
+ StoreConditionsTest::make_store_ready();
+
+ $response = $this->dispatch_ucp( true );
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertTrue( $response->get_data()['enabled'] );
+ $this->assertSame( UcpSwitch::STATUS_ACTIVE, $response->get_data()['status'] );
+ $this->assertSame( 'yes', get_option( 'woocommerce_feature_ucp_enabled' ) );
+
+ $response = $this->dispatch_ucp( false );
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertSame( UcpSwitch::STATUS_OFF, $response->get_data()['status'] );
+ $this->assertSame( 'no', get_option( 'woocommerce_feature_ucp_enabled' ) );
+ }
+
+ /**
+ * @testdox When the UCP option can't be saved, POST ucp answers with an error, not with failing checks.
+ */
+ public function test_ucp_switch_reports_a_failed_save(): void {
+ StoreConditionsTest::make_store_ready();
+ update_option( 'woocommerce_feature_ucp_enabled', 'no' );
+ add_filter( 'pre_update_option_woocommerce_feature_ucp_enabled', static fn( $value, $old_value ) => $old_value, 10, 2 );
+
+ $response = $this->dispatch_ucp( true );
+
+ $this->assertSame( 500, $response->get_status() );
+ $this->assertSame( 'woocommerce_ucp_not_changed', $response->get_data()['code'] );
+ $this->assertSame( 'no', get_option( 'woocommerce_feature_ucp_enabled' ) );
+ }
+
+ /**
+ * @testdox Turning UCP off works while a required check fails.
+ */
+ public function test_ucp_switch_off_with_failing_check(): void {
+ update_option( 'woocommerce_feature_ucp_enabled', 'yes' );
+
+ $response = $this->dispatch_ucp( false );
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertSame( 'no', get_option( 'woocommerce_feature_ucp_enabled' ) );
+ }
+
+ /**
+ * @testdox A user without manage_woocommerce can't switch UCP.
+ */
+ public function test_ucp_switch_requires_manage_woocommerce(): void {
+ StoreConditionsTest::make_store_ready();
+ update_option( 'woocommerce_feature_ucp_enabled', 'no' );
+ wp_set_current_user( self::factory()->user->create( array( 'role' => 'customer' ) ) );
+
+ $this->assertSame( 403, $this->dispatch_ucp( true )->get_status() );
+ $this->assertSame( 'no', get_option( 'woocommerce_feature_ucp_enabled' ) );
+ }
+
+ /**
+ * Send POST wc/v3/agent-readiness/ucp.
+ *
+ * @param bool $enabled Whether to turn UCP on.
+ */
+ private function dispatch_ucp( bool $enabled ): \WP_REST_Response {
+ $request = new WP_REST_Request( 'POST', '/wc/v3/agent-readiness/ucp' );
+ $request->set_param( 'enabled', $enabled );
+ return $this->server->dispatch( $request );
+ }
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/RemoteRulesTest.php b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/RemoteRulesTest.php
index c85312fd9be..a78119b28a1 100644
--- a/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/RemoteRulesTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/RemoteRulesTest.php
@@ -159,7 +159,7 @@ class RemoteRulesTest extends WC_Unit_Test_Case {
}
/**
- * @testdox An effect_from date switches the effect from that day on.
+ * @testdox An effect_from date switches the effect from that day on, and with several dates the latest one already reached wins.
*/
public function test_effect_from_date(): void {
$this->serve(
@@ -168,15 +168,21 @@ class RemoteRulesTest extends WC_Unit_Test_Case {
'google' => array(
'field' => 'image_link',
'effect' => 'gaps',
- 'effect_from' => array( '2027-01-31' => 'not_listable' ),
+ 'effect_from' => array(
+ '2027-06-01' => 'gaps',
+ '2027-01-31' => 'not_listable',
+ ),
),
),
)
);
$this->sut->refresh();
- $this->assertSame( 'gaps', $this->sut->channel_rules( strtotime( '2027-01-30' ) )['image_too_small']['google']['effect'] );
- $this->assertSame( 'not_listable', $this->sut->channel_rules( strtotime( '2027-01-31' ) )['image_too_small']['google']['effect'] );
+ $effect = fn( string $day ): string => $this->sut->channel_rules( strtotime( $day ) )['image_too_small']['google']['effect'];
+ $this->assertSame( 'gaps', $effect( '2027-01-30' ) );
+ $this->assertSame( 'not_listable', $effect( '2027-01-31' ) );
+ $this->assertSame( 'not_listable', $effect( '2027-05-31' ) );
+ $this->assertSame( 'gaps', $effect( '2027-06-01' ), 'Dates are applied in order, not file order' );
}
/**
diff --git a/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/StoreConditionsTest.php b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/StoreConditionsTest.php
new file mode 100644
index 00000000000..e670ac531ac
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/AgentReadiness/StoreConditionsTest.php
@@ -0,0 +1,175 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\AgentReadiness;
+
+use Automattic\WooCommerce\Internal\AgentReadiness\AgenticCommerceSettings;
+use Automattic\WooCommerce\Internal\AgentReadiness\StoreConditions;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the store conditions' levels, which decide whether UCP can be turned on.
+ */
+class StoreConditionsTest extends WC_Unit_Test_Case {
+
+ /**
+ * The System Under Test.
+ *
+ * @var StoreConditions
+ */
+ private $sut;
+
+ /**
+ * Set up test fixtures.
+ */
+ public function setUp(): void {
+ parent::setUp();
+ $this->sut = wc_get_container()->get( StoreConditions::class );
+ }
+
+ /**
+ * @testdox With every required check passing, nothing blocks the switch.
+ */
+ public function test_no_failing_required_checks_on_a_ready_store(): void {
+ self::make_store_ready();
+
+ $this->assertSame( array(), $this->sut->failing_required() );
+ }
+
+ /**
+ * @testdox Coming soon mode is required.
+ */
+ public function test_coming_soon_required(): void {
+ self::make_store_ready();
+ update_option( 'woocommerce_coming_soon', 'yes' );
+
+ $this->assertSame( array( 'launched' ), $this->sut->failing_required() );
+ }
+
+ /**
+ * @testdox A draft or trashed terms page fails its check like an unset one. Policy pages are recommended, so they don't block UCP.
+ *
+ * @testWith ["draft"]
+ * ["trash"]
+ *
+ * @param string $post_status Status of the selected terms page.
+ */
+ public function test_unpublished_policy_page_fails( string $post_status ): void {
+ self::make_store_ready();
+ update_option(
+ AgenticCommerceSettings::TERMS_PAGE_OPTION,
+ self::factory()->post->create(
+ array(
+ 'post_type' => 'page',
+ 'post_status' => $post_status,
+ )
+ )
+ );
+
+ $checks = array_column( $this->sut->all(), null, 'id' );
+
+ $this->assertFalse( $checks['terms_page']['ok'] );
+ $this->assertSame( array(), $this->sut->failing_required() );
+ $this->assertSame( StoreConditions::LEVEL_RECOMMENDED, $checks['privacy_page']['level'] );
+ $this->assertSame( StoreConditions::LEVEL_RECOMMENDED, $checks['terms_page']['level'] );
+ $this->assertSame( StoreConditions::LEVEL_RECOMMENDED, $checks['refund_returns_page']['level'] );
+ $this->assertSame( StoreConditions::LEVEL_RECOMMENDED, $checks['shipping_policy_page']['level'] );
+ }
+
+ /**
+ * @testdox A store page chosen as the terms page under Advanced > Page setup fails the terms check.
+ *
+ * @testWith ["cart"]
+ * ["shop"]
+ * ["myaccount"]
+ *
+ * @param string $store_page Store page used as the terms page.
+ */
+ public function test_store_page_fails_terms_check( string $store_page ): void {
+ self::make_store_ready();
+ $page = self::factory()->post->create(
+ array(
+ 'post_type' => 'page',
+ 'post_status' => 'publish',
+ )
+ );
+ update_option( 'woocommerce_' . $store_page . '_page_id', $page );
+ update_option( AgenticCommerceSettings::TERMS_PAGE_OPTION, $page );
+
+ $checks = array_column( $this->sut->all(), null, 'id' );
+
+ $this->assertFalse( $checks['terms_page']['ok'] );
+ $this->assertSame( StoreConditions::LEVEL_RECOMMENDED, $checks['terms_page']['level'] );
+ $this->assertSame( array(), $this->sut->failing_required() );
+ }
+
+ /**
+ * @testdox Guest checkout off is only recommended, so it doesn't block the switch.
+ */
+ public function test_store_conditions_checks(): void {
+ self::make_store_ready();
+ update_option( 'woocommerce_enable_guest_checkout', 'no' );
+
+ $checks = array_column( $this->sut->all(), null, 'id' );
+
+ $this->assertSame( array(), $this->sut->failing_required() );
+ $this->assertFalse( $checks['guest_checkout']['ok'] );
+ $this->assertSame( StoreConditions::LEVEL_RECOMMENDED, $checks['guest_checkout']['level'] );
+ }
+
+ /**
+ * @testdox Either confirmation email is enough. Both off fails the check.
+ */
+ public function test_order_email_passes_when_either_confirmation_is_on(): void {
+ $emails = WC()->mailer()->get_emails();
+ $processing = $emails['WC_Email_Customer_Processing_Order'];
+ $completed = $emails['WC_Email_Customer_Completed_Order'];
+ $previous = array( $processing->enabled, $completed->enabled );
+ $ok = fn() => array_column( $this->sut->all(), 'ok', 'id' )['order_emails'];
+ try {
+ $processing->enabled = 'no';
+ $completed->enabled = 'no';
+ $this->assertFalse( $ok() );
+
+ $completed->enabled = 'yes';
+ $this->assertTrue( $ok(), 'The Completed order email confirms a virtual order' );
+
+ $completed->enabled = 'no';
+ $processing->enabled = 'yes';
+ $this->assertTrue( $ok() );
+ } finally {
+ $processing->enabled = $previous[0];
+ $completed->enabled = $previous[1];
+ }
+ }
+
+ /**
+ * @testdox HTTPS is required.
+ */
+ public function test_https_required(): void {
+ self::make_store_ready();
+ remove_all_filters( 'pre_option_home' );
+ update_option( 'home', 'http://example.org' );
+
+ $this->assertSame( array( 'https' ), $this->sut->failing_required() );
+ }
+
+ /**
+ * Make every required check pass: the store is launched and the home URL is HTTPS.
+ * Privacy, terms and the refund page are set so those checks count as done.
+ */
+ public static function make_store_ready(): void {
+ add_filter( 'pre_option_home', static fn() => 'https://example.org' );
+ update_option( 'woocommerce_coming_soon', 'no' );
+ $page = wp_insert_post(
+ array(
+ 'post_type' => 'page',
+ 'post_status' => 'publish',
+ 'post_title' => 'Policy',
+ )
+ );
+ foreach ( array( AgenticCommerceSettings::PRIVACY_PAGE_OPTION, AgenticCommerceSettings::TERMS_PAGE_OPTION, AgenticCommerceSettings::REFUND_RETURNS_PAGE_OPTION ) as $option ) {
+ update_option( $option, $page );
+ }
+ }
+}