Commit 232b1e35294 for woocommerce
commit 232b1e3529455d0c57fb72793a8d72de326b0cf3
Author: Michal Iwanow <4765119+mcliwanow@users.noreply.github.com>
Date: Wed Oct 14 12:02:38 2026 +0200
Load the Marketplace task A/B test assignment in the browser (#69467)
* Remove the server-side Marketplace task experiment assignment
The ExPlat request ran inside GET /wc-admin/onboarding/tasks, which the
activity panel calls on most WooCommerce admin pages. The assignment moves
to the home screen in the next commit. MarketplaceTaskExperiment was never
released (added in #69283 during 11.3.0-dev).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Load the Marketplace task A/B test assignment on the home screen
Request the woocommerce_marketplace_task_202611 assignment from the browser
only when the home screen shows the Things to do next list with the
Marketplace task in it, tracking is allowed, and the test hasn't ended.
The list keeps its loading placeholder until the assignment arrives, and
falls back to control after 3 seconds or on any error.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Request the Marketplace task assignment wherever the task list renders
The home screen and the Finish setup panel now follow the same rule: use
a live stored assignment, otherwise request one with the 3 second cap.
The panel only renders the task list once the merchant opens it, so the
request still only happens when the task is shown.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Render other task lists while the Marketplace task assignment loads
Only the extended list, which holds the Marketplace task, shows a placeholder while the ExPlat assignment is requested. The setup list no longer waits up to 3s for it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Format the Marketplace task experiment files with the updated wp-prettier
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/wooairr-457-client-side-marketplace-task-assignment b/plugins/woocommerce/changelog/wooairr-457-client-side-marketplace-task-assignment
new file mode 100644
index 00000000000..4d968da6bd4
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooairr-457-client-side-marketplace-task-assignment
@@ -0,0 +1,3 @@
+Significance: patch
+Type: dev
+Comment: Move the Marketplace task A/B test assignment to the browser; no merchant-facing change.
diff --git a/plugins/woocommerce/client/admin/client/task-lists/marketplace-task-experiment.ts b/plugins/woocommerce/client/admin/client/task-lists/marketplace-task-experiment.ts
new file mode 100644
index 00000000000..1b681768f30
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/task-lists/marketplace-task-experiment.ts
@@ -0,0 +1,186 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { useEffect, useState } from '@wordpress/element';
+import {
+ dangerouslyGetExperimentAssignment,
+ loadExperimentAssignment,
+} from '@woocommerce/explat';
+import type { TaskListType } from '@woocommerce/data';
+
+export const MARKETPLACE_TASK_EXPERIMENT_NAME =
+ 'woocommerce_marketplace_task_202611';
+
+const CONTROL = 'control';
+const COPY_PAYMENTS_SHIPPING_MARKETING = 'copy_payments_shipping_marketing';
+const COPY_FREE_AND_PAID = 'copy_free_and_paid';
+const FIRST_POSITION = 'first_position';
+
+type Variation =
+ | typeof CONTROL
+ | typeof COPY_PAYMENTS_SHIPPING_MARKETING
+ | typeof COPY_FREE_AND_PAID
+ | typeof FIRST_POSITION;
+
+export const MARKETPLACE_TASK_LIST_ID = 'extended';
+const TASK_ID = 'extend-store';
+
+// 2027-03-01 00:00 UTC. No assignment is requested after this.
+const END_TIMESTAMP = Date.UTC( 2027, 2, 1 );
+
+// The ExPlat client waits 5 or 10 seconds before it falls back, too long to hold the home screen list.
+export const ASSIGNMENT_TIMEOUT_MS = 3000;
+
+const toVariation = ( variationName: string | null ): Variation => {
+ switch ( variationName ) {
+ case COPY_PAYMENTS_SHIPPING_MARKETING:
+ case COPY_FREE_AND_PAID:
+ case FIRST_POSITION:
+ return variationName;
+ default:
+ return CONTROL;
+ }
+};
+
+// The Marketplace task when `taskList` is the extended list.
+const findMarketplaceTask = ( taskList: TaskListType | undefined ) =>
+ taskList?.id === MARKETPLACE_TASK_LIST_ID
+ ? taskList.tasks.find( ( { id } ) => id === TASK_ID )
+ : undefined;
+
+/**
+ * Whether the test is running and the visible extended list renders the Marketplace task, so an assignment logs a real exposure.
+ */
+const isMarketplaceTaskInTest = ( taskLists: TaskListType[] ): boolean => {
+ if ( ! window.wcTracks?.isEnabled || Date.now() >= END_TIMESTAMP ) {
+ return false;
+ }
+
+ const taskList = taskLists.find(
+ ( { id } ) => id === MARKETPLACE_TASK_LIST_ID
+ );
+ const task = taskList?.isVisible
+ ? findMarketplaceTask( taskList )
+ : undefined;
+
+ // Same rule TaskList uses to render tasks in an extended list (getVisibleTasks drops dismissed ones).
+ return !! task && ! task.isComplete && ! task.isDismissed;
+};
+
+/**
+ * Apply the variation's title or position to the Marketplace task. Other lists are returned as they are.
+ */
+export const applyMarketplaceTaskVariation = (
+ taskList: TaskListType,
+ variation: Variation
+): TaskListType => {
+ const task = findMarketplaceTask( taskList );
+
+ if ( ! task || variation === CONTROL ) {
+ return taskList;
+ }
+
+ if ( variation === FIRST_POSITION ) {
+ return {
+ ...taskList,
+ tasks: [ task, ...taskList.tasks.filter( ( t ) => t !== task ) ],
+ };
+ }
+
+ const title =
+ variation === COPY_PAYMENTS_SHIPPING_MARKETING
+ ? __(
+ 'Add payments, shipping and marketing extensions',
+ 'woocommerce'
+ )
+ : __( 'Browse free and paid extensions', 'woocommerce' );
+
+ return {
+ ...taskList,
+ tasks: taskList.tasks.map( ( t ) =>
+ t === task ? { ...t, title } : t
+ ),
+ };
+};
+
+type CachedAssignment = { variationName: string | null; isAlive: boolean };
+
+/**
+ * This browser's stored assignment, read without a request; null when nothing is stored.
+ * The client returns expired entries as they are, and a fallback (also logged in development builds) on a miss.
+ */
+const readCachedAssignment = (): CachedAssignment | null => {
+ const assignment = dangerouslyGetExperimentAssignment(
+ MARKETPLACE_TASK_EXPERIMENT_NAME
+ );
+ if ( assignment.isFallbackExperimentAssignment ) {
+ return null;
+ }
+
+ return {
+ variationName: assignment.variationName,
+ isAlive:
+ Date.now() < assignment.retrievedTimestamp + assignment.ttl * 1000,
+ };
+};
+
+/**
+ * Variation for the Marketplace task, decided once per mount when the task is first shown: a live stored assignment
+ * right away, otherwise an ExPlat request. The variation then sticks for the mount, so dismissing the task doesn't
+ * reorder the list.
+ */
+export const useMarketplaceTaskVariation = ( {
+ taskLists,
+ isReady,
+}: {
+ taskLists: TaskListType[];
+ isReady: boolean;
+} ): { isLoading: boolean; variation: Variation } => {
+ const [ state, setState ] = useState< Variation | 'requesting' | null >(
+ null
+ );
+
+ if ( state === null && isReady && isMarketplaceTaskInTest( taskLists ) ) {
+ const cached = readCachedAssignment();
+ setState(
+ cached?.isAlive ? toVariation( cached.variationName ) : 'requesting'
+ );
+ }
+
+ const isRequesting = state === 'requesting';
+
+ useEffect( () => {
+ if ( ! isRequesting ) {
+ return;
+ }
+
+ let isSettled = false;
+ const settle = ( result: Variation ) => {
+ if ( ! isSettled ) {
+ isSettled = true;
+ setState( result );
+ }
+ };
+ const timeoutId = setTimeout(
+ () => settle( CONTROL ),
+ ASSIGNMENT_TIMEOUT_MS
+ );
+
+ loadExperimentAssignment( MARKETPLACE_TASK_EXPERIMENT_NAME )
+ .then( ( { variationName } ) =>
+ settle( toVariation( variationName ) )
+ )
+ .catch( () => settle( CONTROL ) );
+
+ return () => {
+ isSettled = true;
+ clearTimeout( timeoutId );
+ };
+ }, [ isRequesting ] );
+
+ return {
+ isLoading: isRequesting,
+ variation: state === null || isRequesting ? CONTROL : state,
+ };
+};
diff --git a/plugins/woocommerce/client/admin/client/task-lists/task-lists.tsx b/plugins/woocommerce/client/admin/client/task-lists/task-lists.tsx
index 21efb55468e..7f777214e60 100644
--- a/plugins/woocommerce/client/admin/client/task-lists/task-lists.tsx
+++ b/plugins/woocommerce/client/admin/client/task-lists/task-lists.tsx
@@ -4,7 +4,7 @@
import { __ } from '@wordpress/i18n';
import { MenuGroup, MenuItem } from '@wordpress/components';
import { check } from '@wordpress/icons';
-import { Fragment } from '@wordpress/element';
+import { Fragment, useMemo } from '@wordpress/element';
import { useDispatch, useSelect } from '@wordpress/data';
import { onboardingStore, TaskListType, TaskType } from '@woocommerce/data';
import { recordEvent } from '@woocommerce/tracks';
@@ -23,6 +23,11 @@ import { Task } from './components/task';
import { TasksPlaceholder } from './components/placeholder';
import { TaskList } from './components/task-list';
import { getAdminSetting } from '~/utils/admin-settings';
+import {
+ applyMarketplaceTaskVariation,
+ MARKETPLACE_TASK_LIST_ID,
+ useMarketplaceTaskVariation,
+} from './marketplace-task-experiment';
export type TaskListsProps = {
query: { task?: string };
@@ -43,6 +48,22 @@ export const TaskLists = ( { query }: TaskListsProps ) => {
};
}, [] );
+ const { isLoading: isLoadingVariation, variation } =
+ useMarketplaceTaskVariation( {
+ taskLists,
+ isReady: ! task && ! isResolving,
+ } );
+
+ const taskListsWithVariation = useMemo(
+ () =>
+ isResolving
+ ? []
+ : taskLists.map( ( taskList: TaskListType ) =>
+ applyMarketplaceTaskVariation( taskList, variation )
+ ),
+ [ isResolving, taskLists, variation ]
+ );
+
const getCurrentTask = () => {
if ( ! task ) {
return null;
@@ -103,10 +124,19 @@ export const TaskLists = ( { query }: TaskListsProps ) => {
return (
<>
- { taskLists
+ { taskListsWithVariation
.filter( ( { isVisible }: TaskListType ) => isVisible )
.map( ( taskList: TaskListType ) => {
const { id, isHidden, isToggleable } = taskList;
+
+ // Only the list with the Marketplace task waits for the assignment.
+ if (
+ isLoadingVariation &&
+ id === MARKETPLACE_TASK_LIST_ID
+ ) {
+ return <TasksPlaceholder key={ id } query={ query } />;
+ }
+
const TaskListComponent =
id === 'setup' ? SetupTaskList : TaskList;
diff --git a/plugins/woocommerce/client/admin/client/task-lists/test/marketplace-task-experiment.test.tsx b/plugins/woocommerce/client/admin/client/task-lists/test/marketplace-task-experiment.test.tsx
new file mode 100644
index 00000000000..0dd205d2f67
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/task-lists/test/marketplace-task-experiment.test.tsx
@@ -0,0 +1,287 @@
+/**
+ * External dependencies
+ */
+import { act, render, screen } from '@testing-library/react';
+import { useSelect } from '@wordpress/data';
+import {
+ dangerouslyGetExperimentAssignment,
+ loadExperimentAssignment,
+} from '@woocommerce/explat';
+import type { TaskListType, TaskType } from '@woocommerce/data';
+
+/**
+ * Internal dependencies
+ */
+import { TaskLists } from '../task-lists';
+import {
+ ASSIGNMENT_TIMEOUT_MS,
+ MARKETPLACE_TASK_EXPERIMENT_NAME,
+} from '../marketplace-task-experiment';
+
+jest.mock( '@wordpress/data', () => ( {
+ ...jest.requireActual( '@wordpress/data' ),
+ useDispatch: jest.fn().mockReturnValue( {} ),
+ useSelect: jest.fn(),
+} ) );
+
+jest.mock( '@woocommerce/explat', () => ( {
+ dangerouslyGetExperimentAssignment: jest.fn(),
+ loadExperimentAssignment: jest.fn(),
+} ) );
+jest.mock( '@woocommerce/tracks' );
+
+jest.mock( '../components/task-list', () => ( {
+ TaskList: ( { tasks }: TaskListType ) => (
+ <ol>
+ { tasks.map( ( task ) => (
+ <li key={ task.id }>{ task.title }</li>
+ ) ) }
+ </ol>
+ ),
+} ) );
+
+jest.mock( '../components/task', () => ( {
+ Task: () => <div>task-screen</div>,
+} ) );
+
+jest.mock( '../setup-task-list', () => ( {
+ SetupTaskList: () => <div>setup-task-list</div>,
+} ) );
+
+jest.mock( '../components/placeholder', () => ( {
+ TasksPlaceholder: () => <div>task-placeholder</div>,
+} ) );
+
+const CONTROL_TITLE = 'Enhance your store with extensions';
+const NOW = Date.UTC( 2026, 10, 1 );
+const HOME_ORDER = [ 'Grow your business', CONTROL_TITLE ];
+const FIRST_ORDER = [ CONTROL_TITLE, 'Grow your business' ];
+
+const makeTaskLists = (
+ taskOverrides: Partial< TaskType > = {},
+ listOverrides: Partial< TaskListType > = {}
+) => [
+ {
+ id: 'extended',
+ isVisible: true,
+ tasks: [
+ { id: 'marketing', title: 'Grow your business' },
+ { id: 'extend-store', title: CONTROL_TITLE, ...taskOverrides },
+ ],
+ ...listOverrides,
+ },
+];
+
+const mockTaskLists = ( taskLists: unknown[] ) =>
+ ( useSelect as jest.Mock ).mockReturnValue( {
+ isResolving: false,
+ taskLists,
+ } );
+
+const assignment = (
+ variationName: string | null,
+ overrides: Record< string, unknown > = {}
+) => ( {
+ experimentName: MARKETPLACE_TASK_EXPERIMENT_NAME,
+ variationName,
+ retrievedTimestamp: NOW,
+ ttl: 60,
+ ...overrides,
+} );
+
+const mockAssignment = ( variationName: string | null ) =>
+ ( loadExperimentAssignment as jest.Mock ).mockResolvedValue(
+ assignment( variationName )
+ );
+
+const mockCache = ( cached: ReturnType< typeof assignment > ) =>
+ ( dangerouslyGetExperimentAssignment as jest.Mock ).mockReturnValue(
+ cached
+ );
+
+const titles = () =>
+ screen.queryAllByRole( 'listitem' ).map( ( item ) => item.textContent );
+
+describe( 'Marketplace task experiment', () => {
+ const originalWcTracks = window.wcTracks;
+
+ beforeEach( () => {
+ jest.clearAllMocks();
+ jest.spyOn( Date, 'now' ).mockReturnValue( NOW );
+ window.wcTracks = { isEnabled: true };
+ mockTaskLists( makeTaskLists() );
+ // What the ExPlat client returns when nothing is stored.
+ mockCache(
+ assignment( null, { isFallbackExperimentAssignment: true } )
+ );
+ } );
+
+ afterEach( () => {
+ window.wcTracks = originalWcTracks;
+ jest.restoreAllMocks();
+ jest.useRealTimers();
+ } );
+
+ it.each( [
+ [ 'control', HOME_ORDER ],
+ [
+ 'copy_payments_shipping_marketing',
+ [
+ 'Grow your business',
+ 'Add payments, shipping and marketing extensions',
+ ],
+ ],
+ [
+ 'copy_free_and_paid',
+ [ 'Grow your business', 'Browse free and paid extensions' ],
+ ],
+ [ 'first_position', FIRST_ORDER ],
+ [ 'unknown_variation', HOME_ORDER ],
+ [ null, HOME_ORDER ],
+ ] )(
+ 'shows the placeholder, then the %s list',
+ async ( variationName, expectedTitles ) => {
+ mockAssignment( variationName );
+ render( <TaskLists query={ {} } /> );
+
+ expect(
+ screen.getByText( 'task-placeholder' )
+ ).toBeInTheDocument();
+ await screen.findAllByRole( 'listitem' );
+ expect( titles() ).toEqual( expectedTitles );
+ expect( loadExperimentAssignment ).toHaveBeenCalledWith(
+ MARKETPLACE_TASK_EXPERIMENT_NAME
+ );
+ }
+ );
+
+ it( 'renders the other task lists while the assignment loads', async () => {
+ mockAssignment( 'first_position' );
+ mockTaskLists( [
+ { id: 'setup', isVisible: true, tasks: [] },
+ ...makeTaskLists(),
+ ] );
+ render( <TaskLists query={ {} } /> );
+
+ expect( screen.getByText( 'setup-task-list' ) ).toBeInTheDocument();
+ expect( screen.getByText( 'task-placeholder' ) ).toBeInTheDocument();
+ await screen.findAllByRole( 'listitem' );
+ expect(
+ screen.queryByText( 'task-placeholder' )
+ ).not.toBeInTheDocument();
+ } );
+
+ it.each( [
+ [
+ 'tracking is off',
+ () => ( window.wcTracks.isEnabled = false ),
+ HOME_ORDER,
+ ],
+ [
+ 'the experiment has ended',
+ () =>
+ jest
+ .spyOn( Date, 'now' )
+ .mockReturnValue( Date.UTC( 2027, 2, 1 ) ),
+ HOME_ORDER,
+ ],
+ [
+ 'the task is complete',
+ () => mockTaskLists( makeTaskLists( { isComplete: true } ) ),
+ HOME_ORDER,
+ ],
+ [
+ 'the task is dismissed',
+ () => mockTaskLists( makeTaskLists( { isDismissed: true } ) ),
+ HOME_ORDER,
+ ],
+ [
+ 'the task is not in the list',
+ () =>
+ mockTaskLists( [
+ {
+ ...makeTaskLists()[ 0 ],
+ tasks: [
+ { id: 'marketing', title: 'Grow your business' },
+ ],
+ },
+ ] ),
+ [ 'Grow your business' ],
+ ],
+ [
+ 'the list is not visible',
+ () => mockTaskLists( makeTaskLists( {}, { isVisible: false } ) ),
+ [],
+ ],
+ ] )(
+ 'renders control without a request when %s',
+ ( _, arrange, expectedTitles ) => {
+ arrange();
+ mockAssignment( 'first_position' );
+ mockCache( assignment( 'first_position' ) );
+ render( <TaskLists query={ {} } /> );
+
+ expect( titles() ).toEqual( expectedTitles );
+ expect( loadExperimentAssignment ).not.toHaveBeenCalled();
+ expect( dangerouslyGetExperimentAssignment ).not.toHaveBeenCalled();
+ }
+ );
+
+ it( 'does not request an assignment on a task screen', () => {
+ mockAssignment( 'first_position' );
+ render( <TaskLists query={ { task: 'extend-store' } } /> );
+
+ expect( screen.getByText( 'task-screen' ) ).toBeInTheDocument();
+ expect( loadExperimentAssignment ).not.toHaveBeenCalled();
+ } );
+
+ it.each( [
+ [ 'a live stored assignment without a request', {}, FIRST_ORDER, 0 ],
+ [ 'a fresh request for an expired one', { ttl: -1 }, HOME_ORDER, 1 ],
+ ] )(
+ 'uses %s',
+ async ( _, cacheOverrides, expectedTitles, requestCount ) => {
+ mockCache( assignment( 'first_position', cacheOverrides ) );
+ mockAssignment( 'control' );
+ render( <TaskLists query={ {} } /> );
+
+ await screen.findAllByRole( 'listitem' );
+ expect( titles() ).toEqual( expectedTitles );
+ expect( loadExperimentAssignment ).toHaveBeenCalledTimes(
+ requestCount
+ );
+ }
+ );
+
+ it( 'keeps the variation for the mount after the task is dismissed', async () => {
+ mockAssignment( 'first_position' );
+ const { rerender } = render( <TaskLists query={ {} } /> );
+ await screen.findAllByRole( 'listitem' );
+
+ mockTaskLists( makeTaskLists( { isDismissed: true } ) );
+ rerender( <TaskLists query={ {} } /> );
+
+ expect( titles() ).toEqual( FIRST_ORDER );
+ expect( loadExperimentAssignment ).toHaveBeenCalledTimes( 1 );
+ } );
+
+ it( 'falls back to control when the assignment is slow, and ignores a late answer', async () => {
+ jest.useFakeTimers( { now: NOW } );
+ let resolveLate: ( value: unknown ) => void = () => {};
+ ( loadExperimentAssignment as jest.Mock ).mockReturnValue(
+ new Promise( ( resolve ) => ( resolveLate = resolve ) )
+ );
+ render( <TaskLists query={ {} } /> );
+
+ expect( screen.getByText( 'task-placeholder' ) ).toBeInTheDocument();
+ await act( async () => {
+ jest.advanceTimersByTime( ASSIGNMENT_TIMEOUT_MS );
+ } );
+ expect( titles() ).toEqual( HOME_ORDER );
+
+ await act( async () => {
+ resolveLate( assignment( 'first_position' ) );
+ } );
+ expect( titles() ).toEqual( HOME_ORDER );
+ } );
+} );
diff --git a/plugins/woocommerce/src/Admin/API/OnboardingTasks.php b/plugins/woocommerce/src/Admin/API/OnboardingTasks.php
index 3e0a679646a..b3907bb7a23 100644
--- a/plugins/woocommerce/src/Admin/API/OnboardingTasks.php
+++ b/plugins/woocommerce/src/Admin/API/OnboardingTasks.php
@@ -11,7 +11,6 @@ use Automattic\WooCommerce\Admin\Features\Features;
use Automattic\WooCommerce\Admin\Features\OnboardingTasks\DeprecatedExtendedTask;
use Automattic\WooCommerce\Admin\Features\OnboardingTasks\TaskLists;
use Automattic\WooCommerce\Enums\ProductStatus;
-use Automattic\WooCommerce\Internal\Admin\Onboarding\MarketplaceTaskExperiment;
use Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingIndustries;
use Automattic\WooCommerce\Internal\Admin\Onboarding\OnboardingProfile;
use Automattic\WooCommerce\Internal\Utilities\ProductUtil;
@@ -769,12 +768,9 @@ class OnboardingTasks extends \WC_REST_Data_Controller {
$lists = is_array( $task_list_ids ) && count( $task_list_ids ) > 0 ? TaskLists::get_lists_by_ids( $task_list_ids ) : TaskLists::get_lists();
- $marketplace_task_experiment = wc_get_container()->get( MarketplaceTaskExperiment::class );
-
$json = array_map(
- function ( $list ) use ( $marketplace_task_experiment ) { // phpcs:ignore Universal.NamingConventions.NoReservedKeywordParameterNames.listFound
- $marketplace_task_experiment->maybe_move_task_first( $list->sort_tasks() );
- return $list->get_json();
+ function ( $list ) { // phpcs:ignore Universal.NamingConventions.NoReservedKeywordParameterNames.listFound
+ return $list->sort_tasks()->get_json();
},
$lists
);
@@ -1004,8 +1000,7 @@ class OnboardingTasks extends \WC_REST_Data_Controller {
}
$update = $task_list->unhide();
- wc_get_container()->get( MarketplaceTaskExperiment::class )->maybe_move_task_first( $task_list );
- $json = $task_list->get_json();
+ $json = $task_list->get_json();
return rest_ensure_response( $json );
}
diff --git a/plugins/woocommerce/src/Admin/Features/OnboardingTasks/Tasks/ExtendStore.php b/plugins/woocommerce/src/Admin/Features/OnboardingTasks/Tasks/ExtendStore.php
index f5cbf7cfdba..69ed8d92a7b 100644
--- a/plugins/woocommerce/src/Admin/Features/OnboardingTasks/Tasks/ExtendStore.php
+++ b/plugins/woocommerce/src/Admin/Features/OnboardingTasks/Tasks/ExtendStore.php
@@ -3,7 +3,6 @@
namespace Automattic\WooCommerce\Admin\Features\OnboardingTasks\Tasks;
use Automattic\WooCommerce\Admin\Features\OnboardingTasks\Task;
-use Automattic\WooCommerce\Internal\Admin\Onboarding\MarketplaceTaskExperiment;
/**
* ExtendStore Task
@@ -24,16 +23,6 @@ class ExtendStore extends Task {
* @return string
*/
public function get_title() {
- $variation = wc_get_container()->get( MarketplaceTaskExperiment::class )->get_variation( $this );
-
- if ( MarketplaceTaskExperiment::COPY_PAYMENTS_SHIPPING_MARKETING === $variation ) {
- return __( 'Add payments, shipping and marketing extensions', 'woocommerce' );
- }
-
- if ( MarketplaceTaskExperiment::COPY_FREE_AND_PAID === $variation ) {
- return __( 'Browse free and paid extensions', 'woocommerce' );
- }
-
return __( 'Enhance your store with extensions', 'woocommerce' );
}
diff --git a/plugins/woocommerce/src/Internal/Admin/Onboarding/MarketplaceTaskExperiment.php b/plugins/woocommerce/src/Internal/Admin/Onboarding/MarketplaceTaskExperiment.php
deleted file mode 100644
index b14f61251be..00000000000
--- a/plugins/woocommerce/src/Internal/Admin/Onboarding/MarketplaceTaskExperiment.php
+++ /dev/null
@@ -1,172 +0,0 @@
-<?php
-declare( strict_types = 1 );
-
-namespace Automattic\WooCommerce\Internal\Admin\Onboarding;
-
-use Automattic\WooCommerce\Admin\Features\OnboardingTasks\Task;
-use Automattic\WooCommerce\Admin\Features\OnboardingTasks\TaskList;
-use Automattic\WooCommerce\Admin\Features\OnboardingTasks\TaskLists;
-use Automattic\WooCommerce\Proxies\LegacyProxy;
-use WooCommerce\Admin\Experimental_Abtest;
-
-/**
- * ExPlat A/B test for the title and position of the Marketplace ("extend-store") task in the "extended" task list.
- *
- * @since 11.3.0
- */
-final class MarketplaceTaskExperiment {
- /**
- * ExPlat experiment name.
- */
- public const EXPERIMENT_NAME = 'woocommerce_marketplace_task_202611';
-
- /**
- * Current title and position.
- */
- public const CONTROL = 'control';
-
- /**
- * Title "Add payments, shipping and marketing extensions", current position.
- */
- public const COPY_PAYMENTS_SHIPPING_MARKETING = 'copy_payments_shipping_marketing';
-
- /**
- * Title "Browse free and paid extensions", current position.
- */
- public const COPY_FREE_AND_PAID = 'copy_free_and_paid';
-
- /**
- * Current title, task moved to the top of the "extended" list.
- */
- public const FIRST_POSITION = 'first_position';
-
- /**
- * ID of the task under test.
- */
- private const TASK_ID = 'extend-store';
-
- /**
- * Transient set after a failed ExPlat request, so the next requests skip the call for a while.
- */
- private const BACKOFF_TRANSIENT = 'woocommerce_marketplace_task_experiment_backoff';
-
- /**
- * 2027-03-01 00:00 UTC. After this, ExPlat is never asked, so stores that don't update stop calling it.
- */
- private const END_TIMESTAMP = 1803859200;
-
- /**
- * Variation fetched from ExPlat (or its transient) during the current request.
- *
- * @var string|null
- */
- private ?string $variation = null;
-
- /**
- * Get the variation for this request, or control when ExPlat shouldn't be asked.
- *
- * @since 11.3.0
- *
- * @param Task $task The Marketplace task.
- * @return string One of the variation constants.
- */
- public function get_variation( Task $task ): string {
- if ( null === $this->variation && $this->should_request_assignment( $task ) ) {
- $this->variation = $this->request_variation();
- }
-
- return $this->variation ?? self::CONTROL;
- }
-
- /**
- * Move the Marketplace task to the top of the "extended" list when the site is in the first_position variation.
- *
- * @since 11.3.0
- *
- * @param mixed $task_list Task list from TaskLists, which external code can filter, so it may not be a TaskList.
- * @return void
- */
- public function maybe_move_task_first( $task_list ): void {
- if ( ! $task_list instanceof TaskList || 'extended' !== $task_list->get_list_id() ) {
- return;
- }
-
- $task = $task_list->get_task( self::TASK_ID );
- if ( ! $task instanceof Task || self::FIRST_POSITION !== $this->get_variation( $task ) ) {
- return;
- }
-
- $other_tasks = array_filter(
- $task_list->tasks,
- fn( $other_task ) => $other_task !== $task
- );
- $task_list->tasks = array_merge( array( $task ), array_values( $other_tasks ) );
- }
-
- /**
- * Whether to ask ExPlat for an assignment, which logs an exposure.
- *
- * @param Task $task The Marketplace task.
- * @return bool
- */
- private function should_request_assignment( Task $task ): bool {
- if ( $this->has_ended() || ! \WC_Site_Tracking::is_tracking_enabled() || $task->is_dismissed() ) {
- return false;
- }
-
- // Without a tk_ai cookie Experimental_Abtest fails before making a request, so skip it here rather than back off.
- if ( '' === $this->get_anon_id() || get_transient( self::BACKOFF_TRANSIENT ) ) {
- return false;
- }
-
- if ( $task->is_complete() ) {
- return false;
- }
-
- $task_list = TaskLists::get_list( $task->get_parent_id() );
- return ! $task_list || $task_list->is_visible();
- }
-
- /**
- * Whether the end date has passed. Reads the time through LegacyProxy so tests can change it.
- *
- * @return bool
- */
- private function has_ended(): bool {
- return wc_get_container()->get( LegacyProxy::class )->call_function( 'time' ) >= self::END_TIMESTAMP;
- }
-
- /**
- * Get the Tracks anonymous ID from the tk_ai cookie.
- *
- * @return string Empty when the cookie is not set.
- */
- private function get_anon_id(): string {
- return isset( $_COOKIE['tk_ai'] ) ? sanitize_text_field( wp_unslash( $_COOKIE['tk_ai'] ) ) : '';
- }
-
- /**
- * Fetch the assignment from ExPlat (or its transient cache), mapping anything unexpected to control.
- *
- * @return string
- */
- private function request_variation(): string {
- $abtest = new Experimental_Abtest( $this->get_anon_id(), 'woocommerce', true );
-
- try {
- $variation = $abtest->get_variation( self::EXPERIMENT_NAME );
- } catch ( \Exception $e ) {
- // Outside production, get_variation() throws when the request fails instead of returning control.
- $variation = self::CONTROL;
- }
-
- // Experimental_Abtest only caches successful responses, so without a backoff every page load would retry the blocking request.
- if ( empty( get_transient( 'abtest_variation_' . self::EXPERIMENT_NAME ) ) ) {
- set_transient( self::BACKOFF_TRANSIENT, 1, HOUR_IN_SECONDS );
- }
-
- $known = array( self::COPY_PAYMENTS_SHIPPING_MARKETING, self::COPY_FREE_AND_PAID, self::FIRST_POSITION );
-
- return in_array( $variation, $known, true ) ? $variation : self::CONTROL;
- }
-}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Admin/Onboarding/MarketplaceTaskExperimentTest.php b/plugins/woocommerce/tests/php/src/Internal/Admin/Onboarding/MarketplaceTaskExperimentTest.php
deleted file mode 100644
index a7b884b52a3..00000000000
--- a/plugins/woocommerce/tests/php/src/Internal/Admin/Onboarding/MarketplaceTaskExperimentTest.php
+++ /dev/null
@@ -1,262 +0,0 @@
-<?php
-declare( strict_types = 1 );
-
-namespace Automattic\WooCommerce\Tests\Internal\Admin\Onboarding;
-
-use Automattic\WooCommerce\Admin\Features\OnboardingTasks\Task;
-use Automattic\WooCommerce\Admin\Features\OnboardingTasks\TaskList;
-use Automattic\WooCommerce\Admin\Features\OnboardingTasks\TaskLists;
-use Automattic\WooCommerce\Internal\Admin\Onboarding\MarketplaceTaskExperiment;
-use Automattic\WooCommerce\Internal\Admin\WCAdminUser;
-use WC_Unit_Test_Case;
-use WP_REST_Request;
-
-/**
- * Tests for the MarketplaceTaskExperiment class.
- */
-class MarketplaceTaskExperimentTest extends WC_Unit_Test_Case {
-
- /**
- * The System Under Test.
- *
- * @var MarketplaceTaskExperiment
- */
- private $sut;
-
- /**
- * Set up test fixtures.
- */
- public function setUp(): void {
- parent::setUp();
-
- TaskLists::clear_lists();
- TaskLists::init_default_lists();
-
- $this->sut = new MarketplaceTaskExperiment();
- wc_get_container()->replace( MarketplaceTaskExperiment::class, $this->sut );
-
- // WP_HTTP_TestCase records every request in $this->http_requests; never reach the live ExPlat API.
- $this->http_responder = fn() => array(
- 'response' => array( 'code' => 200 ),
- 'body' => wp_json_encode(
- array(
- 'variations' => array( MarketplaceTaskExperiment::EXPERIMENT_NAME => MarketplaceTaskExperiment::FIRST_POSITION ),
- 'ttl' => 60,
- )
- ),
- );
-
- update_option( 'woocommerce_allow_tracking', 'yes' );
- $_COOKIE['tk_ai'] = 'test-anon-id';
-
- // Pin the clock before the end date so these tests keep passing after the experiment ends.
- $this->register_legacy_proxy_function_mocks( array( 'time' => fn() => 1803859200 - 1 ) );
- }
-
- /**
- * Tear down test fixtures.
- */
- public function tearDown(): void {
- try {
- unset( $_COOKIE['tk_ai'] );
- $this->reset_legacy_proxy_mocks();
- wc_get_container()->reset_all_replacements();
- TaskLists::clear_lists();
- TaskLists::init_default_lists();
- } finally {
- parent::tearDown();
- }
- }
-
- /**
- * Name of the backoff transient set after a failed request (mirrors the private constant in MarketplaceTaskExperiment).
- */
- private const BACKOFF_TRANSIENT = 'woocommerce_marketplace_task_experiment_backoff';
-
- /**
- * Get the Marketplace task from the default "extended" list.
- *
- * @return Task
- */
- private function get_task(): Task {
- return TaskLists::get_task( 'extend-store', 'extended' );
- }
-
- /**
- * @testdox Should return control without contacting ExPlat when tracking is disabled.
- */
- public function test_returns_control_without_request_when_tracking_disabled(): void {
- update_option( 'woocommerce_allow_tracking', 'no' );
- set_transient( 'abtest_variation_' . MarketplaceTaskExperiment::EXPERIMENT_NAME, MarketplaceTaskExperiment::COPY_FREE_AND_PAID );
-
- $this->assertSame( MarketplaceTaskExperiment::CONTROL, $this->sut->get_variation( $this->get_task() ) );
- $this->assertSame( 'Enhance your store with extensions', $this->get_task()->get_title() );
- $this->assertCount( 0, $this->http_requests, 'No assignment should be requested without tracking consent' );
- }
-
- /**
- * @testdox Should use the title for the assigned variation.
- *
- * @testWith ["copy_payments_shipping_marketing", "Add payments, shipping and marketing extensions"]
- * ["copy_free_and_paid", "Browse free and paid extensions"]
- * ["first_position", "Enhance your store with extensions"]
- * ["unknown_variation", "Enhance your store with extensions"]
- *
- * @param string $variation Cached ExPlat assignment.
- * @param string $expected_title Expected task title.
- */
- public function test_title_matches_assigned_variation( string $variation, string $expected_title ): void {
- set_transient( 'abtest_variation_' . MarketplaceTaskExperiment::EXPERIMENT_NAME, $variation );
-
- $this->assertSame( $expected_title, $this->get_task()->get_title() );
- $this->assertCount( 0, $this->http_requests, 'A cached assignment should not trigger a request' );
- }
-
- /**
- * @testdox Should move the task to the top of the extended list only for first_position.
- *
- * @testWith ["first_position", true]
- * ["copy_free_and_paid", false]
- *
- * @param string $variation Cached ExPlat assignment.
- * @param bool $expect_moved Whether the task should be first.
- */
- public function test_tasks_endpoint_moves_task_first_for_first_position( string $variation, bool $expect_moved ): void {
- $default_ids = $this->prepare_extended_list( $variation );
-
- $request = new WP_REST_Request( 'GET', '/wc-admin/onboarding/tasks' );
- $request->set_param( 'ids', array( 'extended' ) );
- $ids = array_column( rest_get_server()->dispatch( $request )->get_data()[0]['tasks'], 'id' );
-
- $this->assertSame( $expect_moved ? $this->move_task_first( $default_ids ) : $default_ids, $ids );
- }
-
- /**
- * @testdox Should return the task first when the extended list is unhidden for first_position.
- */
- public function test_unhide_endpoint_moves_task_first_for_first_position(): void {
- $default_ids = $this->prepare_extended_list( MarketplaceTaskExperiment::FIRST_POSITION );
- update_option( TaskList::HIDDEN_OPTION, array( 'extended' ) );
- $this->assertSame( MarketplaceTaskExperiment::CONTROL, $this->sut->get_variation( $this->get_task() ), 'A hidden list should resolve to control' );
-
- $data = rest_get_server()->dispatch( new WP_REST_Request( 'POST', '/wc-admin/onboarding/tasks/extended/unhide' ) )->get_data();
-
- $this->assertSame( $this->move_task_first( $default_ids ), array_column( $data['tasks'], 'id' ) );
- }
-
- /**
- * Seed the assignment, log in as an admin, and return the extended list's default task order.
- *
- * @param string $variation Cached ExPlat assignment.
- * @return string[]
- */
- private function prepare_extended_list( string $variation ): array {
- set_transient( 'abtest_variation_' . MarketplaceTaskExperiment::EXPERIMENT_NAME, $variation );
- wp_set_current_user( $this->factory->user->create( array( 'role' => 'administrator' ) ) );
- $default_ids = array_map( fn( $task ) => $task->get_id(), TaskLists::get_list( 'extended' )->get_viewable_tasks() );
- $this->assertNotSame( 'extend-store', $default_ids[0], 'The task should not be first by default' );
-
- return $default_ids;
- }
-
- /**
- * Expected order with the Marketplace task first and the rest unchanged.
- *
- * @param string[] $ids Default task order.
- * @return string[]
- */
- private function move_task_first( array $ids ): array {
- return array_merge( array( 'extend-store' ), array_values( array_diff( $ids, array( 'extend-store' ) ) ) );
- }
-
- /**
- * @testdox Should fetch the assignment from ExPlat and cache it when no transient is set.
- *
- * @testWith ["copy_free_and_paid", "copy_free_and_paid"]
- * [null, "control"]
- *
- * @param string|null $assigned Variation in the ExPlat response, or null for an empty response.
- * @param string $expected Expected variation.
- */
- public function test_fetches_assignment_from_explat( ?string $assigned, string $expected ): void {
- $body = null === $assigned
- ? array(
- 'variations' => new \stdClass(),
- 'assignments' => new \stdClass(),
- 'ttl' => 7200,
- )
- : array(
- 'variations' => array( MarketplaceTaskExperiment::EXPERIMENT_NAME => $assigned ),
- 'ttl' => 3600,
- );
- $this->http_responder = fn() => array(
- 'response' => array( 'code' => 200 ),
- 'body' => wp_json_encode( $body ),
- );
-
- $this->assertSame( $expected, $this->sut->get_variation( $this->get_task() ) );
- $this->assertCount( 1, $this->http_requests );
- wp_parse_str( (string) wp_parse_url( $this->http_requests[0]['url'], PHP_URL_QUERY ), $args );
- $this->assertSame( MarketplaceTaskExperiment::EXPERIMENT_NAME, $args['experiment_name'] );
- $this->assertSame( 'test-anon-id', $args['anon_id'] );
- $this->assertSame( $expected, get_transient( 'abtest_variation_' . MarketplaceTaskExperiment::EXPERIMENT_NAME ) );
- $this->assertFalse( get_transient( self::BACKOFF_TRANSIENT ) );
- }
-
- /**
- * @testdox Should fall back to control and stop requesting for a while when the request fails.
- *
- * @testWith ["wp_error"]
- * ["server_error"]
- *
- * @param string $failure Kind of failed response.
- */
- public function test_backs_off_when_request_fails( string $failure ): void {
- $this->http_responder = 'wp_error' === $failure
- ? fn() => new \WP_Error( 'http_request_failed', 'Offline' )
- : fn() => array(
- 'response' => array( 'code' => 500 ),
- 'body' => 'Internal Server Error',
- );
-
- $this->assertSame( MarketplaceTaskExperiment::CONTROL, $this->sut->get_variation( $this->get_task() ) );
- $this->assertNotEmpty( get_transient( self::BACKOFF_TRANSIENT ), 'A failed request should set the backoff' );
-
- $this->assertSame( MarketplaceTaskExperiment::CONTROL, ( new MarketplaceTaskExperiment() )->get_variation( $this->get_task() ) );
- $this->assertCount( 1, $this->http_requests, 'No request should be made during the backoff' );
- }
-
- /**
- * @testdox Should not request an assignment when the task is dismissed, complete, its list is hidden, there is no tk_ai cookie, tracking is filtered off, or the experiment has ended.
- *
- * @testWith ["dismissed"]
- * ["visited"]
- * ["hidden_list"]
- * ["no_anon_id"]
- * ["tracking_filtered_off"]
- * ["ended"]
- *
- * @param string $state Task state that should skip the request.
- */
- public function test_skips_request_for_inactive_task( string $state ): void {
- if ( 'dismissed' === $state ) {
- update_option( Task::DISMISSED_OPTION, array( 'extend-store' ) );
- } elseif ( 'visited' === $state ) {
- $user_id = $this->factory->user->create( array( 'role' => 'administrator' ) );
- wp_set_current_user( $user_id );
- WCAdminUser::update_user_data_field( $user_id, 'task_list_tracked_started_tasks', wp_json_encode( array( 'extend-store' => 1 ) ) );
- } elseif ( 'hidden_list' === $state ) {
- update_option( TaskList::HIDDEN_OPTION, array( 'extended' ) );
- } elseif ( 'no_anon_id' === $state ) {
- unset( $_COOKIE['tk_ai'] );
- } elseif ( 'tracking_filtered_off' === $state ) {
- add_filter( 'woocommerce_apply_user_tracking', '__return_false' );
- } else {
- $this->register_legacy_proxy_function_mocks( array( 'time' => fn() => 1803859200 ) );
- }
-
- $this->assertSame( MarketplaceTaskExperiment::CONTROL, $this->sut->get_variation( $this->get_task() ) );
- $this->assertCount( 0, $this->http_requests, 'No assignment should be requested' );
- $this->assertFalse( get_transient( self::BACKOFF_TRANSIENT ), 'A skipped request should not set the backoff' );
- }
-}