Commit db0994e7f1b for woocommerce

commit db0994e7f1b031fe4a63f19cd2d52d2f8efaa769
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date:   Tue Sep 15 17:08:53 2026 +0300

    [tests] Demote onboarding profiler and add-product coverage below E2E (#68634)

    * test(onboarding): Move profiler and add-product coverage below E2E

    Onboarding had seven browser titles over two screens: the core
    profiler, and the add-product task that catches a merchant who opens
    Products before the store has any. Most of what they asserted is
    decided in PHP, in the redirect guard and the extension bundle, or in
    React, in the plugins page that offers the recommendations.

    Move those contracts down. A new ProductsTest covers the redirect:
    which screens it fires on, that a published product suppresses it,
    that an auto-draft does not, and that the task stays accessible when
    its list is hidden. DefaultFreeExtensionsTest gains a data-driven
    check of which plugins the profiler bundle offers per store country.
    The Plugins page's Jest suite is restructured around the four
    recommendations the profiler actually ships.

    Keep three browser titles: completing the profiler while skipping the
    extension install, skipping the guided setup, and the redirect a
    merchant hits on an empty Products page.

    One title is removed without a browser replacement: completing the
    profiler while installing the default extensions. That is deliberate
    and the portfolio marks it deferred, because the install needs either
    hermetic plugin metadata or a live-external lane, and neither exists
    yet. Its install service keeps a Jest owner.

    site.setup.ts gains one step, so this touches every Playwright
    project that depends on the site fixture. It dismisses the two email
    improvements prompts, which otherwise redirect any page=wc-admin
    request that carries no path -- exactly the URL the retained
    add-product title asserts on.

    Two things here differ from the migration branch. The retained
    add-product title's cleanup was a try/catch block with an
    AggregateError; it is now a test.afterEach, which keeps the same
    teardown without the conditionals that made the file lint dirty. And
    a fixture product named after an internal work item is renamed.

    Consolidates five mega-branch commits, subjects verbatim:
    - test(admin): Reduce onboarding wizard browser coverage
    - test(onboarding): Consolidate add product task coverage
    - test(onboarding): Align add product task coverage with trunk
    - test(e2e): Reset profiler country between runs
    - fix(e2e): synchronize Core admin interactions (site.setup.ts hunk
      only; that commit also touches three specs owned by other batches)

    Refs TESTOPS-288
    Refs #68046

    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

    * test(core): Delete only this spec's products in teardown

    The new `afterEach` fetched every product the endpoint returned and
    batch-deleted the lot. The site is shared with the rest of the run and
    there is no per-spec database restore in the core E2E config, so that
    would take other specs' fixtures with it.

    Track the ids this spec creates instead, and delete those. The id is
    dropped from the list once the test deletes it itself, so on a passing
    run there is nothing left to clean.

    The status assertion now runs only when a delete actually happened.
    Asserting 200 unconditionally would have passed whether or not one did.

    The `beforeAll` sweep is left alone: this spec needs a store with no
    products to redirect at all, and that part is trunk's.

    Refs #68634

    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

    * test(onboarding): Stop the onboarding tests restoring base-class state

    Both classes snapshotted option rows straight out of the options table
    in setUp() and wrote them back in tearDown(). ProductsTest also
    restored $GLOBALS['current_screen'], removed the eight callbacks the
    Products constructor registers, removed its redirect filter and deleted
    its products.

    tear_down() rolls the transaction back, nulls the current-screen
    globals and restores the hooks, so all of it was already handled. That
    leaves ProductsTest with nothing to tear down, so the override goes.

    DefaultFreeExtensionsTest restored its rows *after* calling
    parent::tearDown(), which put those writes outside the transaction
    entirely. Dropping the apparatus removes that as well.

    Assertions move from 61 to 28 across the same 15 tests. Every one that
    went was inside the restore helpers -- assertNotFalse on each row write,
    and the pass that re-read the rows to confirm the restore matched. No
    assertion about onboarding behaviour was touched.

    Refs TESTOPS-288

    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

    * test(e2e): Confirm the product delete before untracking it

    The test spliced the product id out of createdProductIds before
    asserting the delete returned 200. A failed delete therefore left a
    published product that the afterEach sweep no longer knew about -- in a
    spec whose entire premise is that no products exist. Assert first, then
    untrack.

    The sweep itself had the same blind spot from the other direction. The
    products batch endpoint reports per-item failures in the response body
    and still returns 200, so checking the status alone said nothing about
    whether anything was deleted. It now also asserts every requested id came
    back in the delete list with no error attached.

    Refs TESTOPS-288

    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

    * test(e2e): Keep unconfirmed product ids for the next teardown

    The add-product task teardown drained createdProductIds before the batch
    delete ran. The batch endpoint reports a failed deletion as an item with
    an error and still answers 200, so a product that failed to delete was
    no longer tracked and a later afterEach could not retry it.

    Copy the ids instead, and after the batch keep only those it did not
    confirm deleted. A thrown request now leaves every id tracked too.

    Refs TESTOPS-288

    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

    * test(e2e): Read batch delete results defensively in teardown

    An extension filter on the product delete response can reshape the batch
    results. A null entry made the id lookup throw during teardown, and the
    error-only assertion let such an entry pass while its id stayed tracked.

    Accept the results only as an array, read each id null-safely, and
    assert that no created id is left unconfirmed. That one assertion covers
    errored, missing and null entries alike.

    Refs TESTOPS-288

    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

    * test(e2e): Keep the profiler install journey with stubbed installs

    Moving onboarding below E2E removed the title that completed the core
    profiler with extensions selected, because it downloaded plugins from
    WordPress.org. Its install checks only logged failures, but its
    home-screen assertion did guard the journey, and nothing owned that
    afterwards.

    The new title selects Google for WooCommerce, answers the install and
    activate requests with page.route using the REST controller's success
    shape, and asserts the exact requests, the saved business_extensions and
    the home screen. A stubbed install error fails it at the home screen;
    selecting another offered extension fails it at the request assertion.

    Refs TESTOPS-288

    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

    * test(e2e): Reset the store location before each profiler test

    The extension-selected profiler title failed its first attempt in every
    serial shard and passed only on retry. The title before it completes
    the profiler with Australia — Northern Territory, which saves that
    location and marks the store country as set, so the next profiler run
    pre-fills the location field with it. The location list leaves out the
    selected option, so the click on that option timed out. A retry passed
    because beforeAll ran again on the new worker and moved the saved
    country back to US:CA.

    Run the reset in beforeEach so every title starts from US:CA and can
    pick the same location, and drop the comment that said the field starts
    empty.

    Refs TESTOPS-288

    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

    ---------

    Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

diff --git a/plugins/woocommerce/changelog/testops-288-onboarding b/plugins/woocommerce/changelog/testops-288-onboarding
new file mode 100644
index 00000000000..52980851752
--- /dev/null
+++ b/plugins/woocommerce/changelog/testops-288-onboarding
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+Comment: Move onboarding profiler and add-product coverage below E2E; seven browser titles become three.
+
diff --git a/plugins/woocommerce/client/admin/client/core-profiler/pages/Plugins/test/Plugins.test.tsx b/plugins/woocommerce/client/admin/client/core-profiler/pages/Plugins/test/Plugins.test.tsx
index f4f9f41da21..0d23265d043 100644
--- a/plugins/woocommerce/client/admin/client/core-profiler/pages/Plugins/test/Plugins.test.tsx
+++ b/plugins/woocommerce/client/admin/client/core-profiler/pages/Plugins/test/Plugins.test.tsx
@@ -1,7 +1,8 @@
 /**
  * External dependencies
  */
-import { render, screen, fireEvent } from '@testing-library/react';
+import { render, screen, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
 import { Extension } from '@woocommerce/data';

 /**
@@ -9,53 +10,63 @@ import { Extension } from '@woocommerce/data';
  */
 import { computePluginsSelection, joinWithAnd, Plugins } from '../Plugins';

+const getPluginCheckbox = ( name: string ) => {
+	const card = screen
+		.getByRole( 'heading', { level: 3, name } )
+		.closest( '.woocommerce-profiler-plugins-plugin-card' );
+
+	expect( card ).not.toBeNull();
+
+	return within( card! ).getByRole( 'checkbox' );
+};
+
 describe( 'Plugins Component', () => {
 	const mockSendEvent = jest.fn();
 	const mockContext = {
 		pluginsAvailable: [
 			{
-				slug: 'plugin1',
-				name: 'Plugin 1',
-				label: 'Plugin 1',
+				slug: 'woocommerce-payments',
+				name: 'WooPayments',
+				label: 'WooPayments',
 				is_activated: false,
 				description: '',
-				key: 'plugin1',
+				key: 'woocommerce-payments',
 				image_url: '',
 				manage_url: '',
 				is_built_by_wc: false,
 				is_visible: true,
 			},
 			{
-				slug: 'plugin2',
-				name: 'Plugin 2',
-				label: 'Plugin 2',
-				is_activated: true,
+				slug: 'google-listings-and-ads',
+				name: 'Google for WooCommerce',
+				label: 'Google for WooCommerce',
+				is_activated: false,
 				description: '',
-				key: 'plugin2',
+				key: 'google-listings-and-ads',
 				image_url: '',
 				manage_url: '',
 				is_built_by_wc: false,
 				is_visible: true,
 			},
 			{
-				slug: 'plugin3',
-				name: 'Plugin 3',
-				label: 'Plugin 3',
-				is_activated: false,
+				slug: 'jetpack',
+				name: 'Jetpack',
+				label: 'Jetpack',
+				is_activated: true,
 				description: '',
-				key: 'plugin3',
+				key: 'jetpack',
 				image_url: '',
 				manage_url: '',
 				is_built_by_wc: false,
 				is_visible: true,
 			},
 			{
-				slug: 'plugin4',
-				name: 'Plugin 4',
-				label: 'Plugin 4',
+				slug: 'mailpoet',
+				name: 'MailPoet',
+				label: 'MailPoet',
 				is_activated: false,
 				description: '',
-				key: 'plugin4',
+				key: 'mailpoet:alt',
 				image_url: '',
 				manage_url: '',
 				is_built_by_wc: false,
@@ -66,6 +77,9 @@ describe( 'Plugins Component', () => {
 		pluginsInstallationErrors: [],
 	};
 	const navigationProgress = 80;
+	beforeEach( () => {
+		mockSendEvent.mockClear();
+	} );

 	it( 'renders correctly', () => {
 		render(
@@ -80,13 +94,17 @@ describe( 'Plugins Component', () => {
 				/No commitment required – you can remove them at any time/
 			)
 		).toBeInTheDocument();
-		expect( screen.getByText( 'Plugin 1' ) ).toBeInTheDocument();
-		expect( screen.getByText( 'Plugin 2' ) ).toBeInTheDocument();
-		expect( screen.getByText( 'Plugin 3' ) ).toBeInTheDocument();
-		expect( screen.getByText( 'Plugin 4' ) ).toBeInTheDocument();
+		expect(
+			screen.getByRole( 'heading', { level: 3, name: 'WooPayments' } )
+		).toBeInTheDocument();
+		expect(
+			screen.getByText( 'Google for WooCommerce' )
+		).toBeInTheDocument();
+		expect( screen.getByText( 'Jetpack' ) ).toBeInTheDocument();
+		expect( screen.getByText( 'MailPoet' ) ).toBeInTheDocument();
 	} );

-	it( 'handles plugin selection', () => {
+	it( 'selects each default inactive recommendation', () => {
 		render(
 			<Plugins
 				context={ mockContext }
@@ -94,83 +112,52 @@ describe( 'Plugins Component', () => {
 				navigationProgress={ navigationProgress }
 			/>
 		);
-		const checkboxLabel = screen.getByText( 'Plugin 1' );
-		fireEvent.click( checkboxLabel ); // because the checkbox is enabled by default, let's uncheck it
-		fireEvent.click( checkboxLabel ); // then check it
-		const checkboxLabel3 = screen.getByText( 'Plugin 3' );
-		fireEvent.click( checkboxLabel3 );
-		const checkboxLabel4 = screen.getByText( 'Plugin 4' ); // attempt to uncheck 4, but it shouldn't do anything since it is already unchecked
-		fireEvent.click( checkboxLabel4 );
-		const installButton = screen.getByText( 'Continue' );
-		fireEvent.click( installButton );

-		expect( mockSendEvent ).toHaveBeenCalledWith( {
-			type: 'PLUGINS_INSTALLATION_REQUESTED',
-			payload: {
-				pluginsSelected: [ 'plugin1' ],
-				pluginsShown: [ 'plugin1', 'plugin2', 'plugin3', 'plugin4' ],
-				pluginsUnselected: [ 'plugin3', 'plugin4' ],
-			},
-		} );
+		expect( getPluginCheckbox( 'WooPayments' ) ).toBeChecked();
+		expect( getPluginCheckbox( 'Google for WooCommerce' ) ).toBeChecked();
+		expect( getPluginCheckbox( 'MailPoet' ) ).toBeChecked();
+		expect(
+			screen
+				.getByText( 'Jetpack' )
+				.closest( '.woocommerce-profiler-plugins-plugin-card' )
+		).toHaveClass( 'is-installed' );
 	} );

-	it( 'handles case where all plugins are already installed', () => {
+	it( 'completes without selecting when every plugin is installed', async () => {
 		render(
 			<Plugins
 				context={ {
 					...mockContext,
 					pluginsAvailable: mockContext.pluginsAvailable.map(
-						( plugin ) => ( {
-							...plugin,
-							is_activated: true,
-						} )
+						( plugin ) => ( { ...plugin, is_activated: true } )
 					),
 				} }
 				sendEvent={ mockSendEvent }
 				navigationProgress={ navigationProgress }
 			/>
 		);
-		const plugin1Card = screen
-			.getByText( 'Plugin 1' )
-			.closest( '.woocommerce-profiler-plugins-plugin-card' );
-		expect( plugin1Card ).toHaveClass( 'is-installed' );
-		expect( plugin1Card ).toHaveTextContent( 'Installed' );
+
 		expect(
 			screen
-				.getByText( 'Plugin 2' )
+				.getByRole( 'heading', { level: 3, name: 'WooPayments' } )
 				.closest( '.woocommerce-profiler-plugins-plugin-card' )
 		).toHaveTextContent( 'Installed' );
-		const continueButton = screen.getByText( 'Continue' );
-		fireEvent.click( continueButton );
+
+		await userEvent.click( screen.getByText( 'Continue' ) );
+
 		expect( mockSendEvent ).toHaveBeenCalledWith( {
 			type: 'PLUGINS_PAGE_COMPLETED_WITHOUT_SELECTING_PLUGINS',
 		} );
 	} );

-	it( 'initialises with all plugins selected when there were no errors previously', () => {
-		render(
-			<Plugins
-				context={ mockContext }
-				sendEvent={ mockSendEvent }
-				navigationProgress={ navigationProgress }
-			/>
-		);
-		const checkboxLabels = screen.getAllByRole( 'checkbox' );
-		expect( checkboxLabels ).toHaveLength( 3 );
-		checkboxLabels.forEach( ( checkbox ) => {
-			expect( checkbox ).toBeChecked();
-		} );
-	} );
-
-	it( 'initialises with the previous selection correctly when there were errors previously', () => {
+	it( 'retries the previous selection after an installation error', async () => {
 		render(
 			<Plugins
 				context={ {
 					...mockContext,
-					pluginsAvailable: [ ...mockContext.pluginsAvailable ],
 					pluginsInstallationErrors: [
 						{
-							plugin: 'plugin4',
+							plugin: 'woocommerce-payments',
 							error: 'Installation failed',
 							errorDetails: {
 								data: {
@@ -182,36 +169,72 @@ describe( 'Plugins Component', () => {
 							},
 						},
 					],
-					pluginsSelected: [ 'plugin4' ],
+					pluginsSelected: [ 'woocommerce-payments', 'mailpoet:alt' ],
 				} }
 				sendEvent={ mockSendEvent }
 				navigationProgress={ navigationProgress }
 			/>
 		);
+
 		expect(
 			screen.getByText(
 				/Oops! We encountered a problem while installing/
 			)
 		).toBeInTheDocument();
-		const checkbox1 = screen
-			.getByText( 'Plugin 1' )
-			.closest( '.woocommerce-profiler-plugins-plugin-card' )
-			?.querySelector( 'input[type="checkbox"]' );
-		expect( checkbox1 ).not.toBeChecked();
-		const checkbox3 = screen
-			.getByText( 'Plugin 3' )
-			.closest( '.woocommerce-profiler-plugins-plugin-card' )
-			?.querySelector( 'input[type="checkbox"]' );
-		expect( checkbox3 ).not.toBeChecked();
-		const checkbox4 = screen
-			// use role because error message also contains the plugin name
-			.getByRole( 'heading', { level: 3, name: 'Plugin 4' } )
-			.closest( '.woocommerce-profiler-plugins-plugin-card' )
-			?.querySelector( 'input[type="checkbox"]' );
-		expect( checkbox4 ).toBeChecked();
+		expect( getPluginCheckbox( 'WooPayments' ) ).toBeChecked();
+		expect(
+			getPluginCheckbox( 'Google for WooCommerce' )
+		).not.toBeChecked();
+		expect( getPluginCheckbox( 'MailPoet' ) ).toBeChecked();
+
+		await userEvent.click( screen.getByText( 'Please try again' ) );
+
+		expect( mockSendEvent ).toHaveBeenCalledWith( {
+			type: 'PLUGINS_INSTALLATION_REQUESTED',
+			payload: {
+				pluginsShown: [
+					'woocommerce-payments',
+					'google-listings-and-ads',
+					'jetpack',
+					'mailpoet',
+				],
+				pluginsSelected: [ 'woocommerce-payments', 'mailpoet' ],
+				pluginsUnselected: [ 'google-listings-and-ads' ],
+			},
+		} );
+	} );
+
+	it( 'submits normalized shown, selected, and unselected plugin keys', async () => {
+		render(
+			<Plugins
+				context={ mockContext }
+				sendEvent={ mockSendEvent }
+				navigationProgress={ navigationProgress }
+			/>
+		);
+
+		await userEvent.click( getPluginCheckbox( 'MailPoet' ) );
+		await userEvent.click( screen.getByText( 'Continue' ) );
+
+		expect( mockSendEvent ).toHaveBeenCalledWith( {
+			type: 'PLUGINS_INSTALLATION_REQUESTED',
+			payload: {
+				pluginsShown: [
+					'woocommerce-payments',
+					'google-listings-and-ads',
+					'jetpack',
+					'mailpoet',
+				],
+				pluginsSelected: [
+					'woocommerce-payments',
+					'google-listings-and-ads',
+				],
+				pluginsUnselected: [ 'mailpoet' ],
+			},
+		} );
 	} );

-	it( 'handles skip action', () => {
+	it( 'handles skip action', async () => {
 		render(
 			<Plugins
 				context={ mockContext }
@@ -220,7 +243,7 @@ describe( 'Plugins Component', () => {
 			/>
 		);
 		const skipButton = screen.getByText( 'Skip this step' );
-		fireEvent.click( skipButton );
+		await userEvent.click( skipButton );
 		expect( mockSendEvent ).toHaveBeenCalledWith( {
 			type: 'PLUGINS_PAGE_SKIPPED',
 		} );
diff --git a/plugins/woocommerce/tests/e2e/fixtures/site.setup.ts b/plugins/woocommerce/tests/e2e/fixtures/site.setup.ts
index 9aef9137c0b..595eb096dd8 100644
--- a/plugins/woocommerce/tests/e2e/fixtures/site.setup.ts
+++ b/plugins/woocommerce/tests/e2e/fixtures/site.setup.ts
@@ -87,6 +87,21 @@ setup( 'setup site', async ( { baseURL, restApi } ) => {
 		await skipOnboardingWizard();
 	} );

+	await setup.step( 'dismiss email improvements prompts', async () => {
+		await setOption(
+			request,
+			baseURL,
+			'woocommerce_admin_dismissed_email_improvements_modal',
+			'yes'
+		);
+		await setOption(
+			request,
+			baseURL,
+			'woocommerce_admin_dismissed_try_email_improvements_modal',
+			'yes'
+		);
+	} );
+
 	await setup.step( 'determine if multisite', async () => {
 		const response = await restApi.get( `${ WC_API_PATH }/system_status` );
 		const { environment } = response.data;
diff --git a/plugins/woocommerce/tests/e2e/tests/onboarding/add-product-task.spec.ts b/plugins/woocommerce/tests/e2e/tests/onboarding/add-product-task.spec.ts
index 5bf49547d42..d3a5b8398a3 100644
--- a/plugins/woocommerce/tests/e2e/tests/onboarding/add-product-task.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/onboarding/add-product-task.spec.ts
@@ -41,6 +41,9 @@ const show_task_list = async ( restApi: any, task_list_name: string ) => {
 test.describe( 'Add Product Task', () => {
 	test.use( { storageState: ADMIN_STATE_PATH } );

+	// Ids this spec creates, so teardown can delete its own products and nobody else's.
+	const createdProductIds: number[] = [];
+
 	test.beforeAll( async ( { restApi } ) => {
 		const productIds = [];

@@ -80,19 +83,72 @@ test.describe( 'Add Product Task', () => {
 		} );
 	} );

+	test.afterEach( async ( { restApi } ) => {
+		// The test leaves a product behind if it fails before its own delete, and leaves the
+		// setup task list hidden if it fails between hiding and showing it. Both are torn down
+		// here rather than in the body so that a failure reports the assertion that failed
+		// instead of a cleanup error raised on the way out.
+		//
+		// Only this spec's own products are deleted. The site is shared with the rest of the
+		// run, so sweeping every id the products endpoint returns would take other specs'
+		// fixtures with it.
+		// Copy rather than drain: an id stays tracked until the batch confirms it deleted, so a
+		// later afterEach retries whatever this one could not remove.
+		const idsToDelete = [ ...createdProductIds ];
+		// Every teardown call runs before anything is asserted: a failed delete must not stop
+		// the task list from being unhidden, or the rest of this serial project inherits it.
+		const deleteResponse = idsToDelete.length
+			? await restApi.post( `${ WC_API_PATH }/products/batch`, {
+					delete: idsToDelete,
+			  } )
+			: null;
+		const taskListShown = await show_task_list( restApi, 'setup' );
+
+		// On a passing run the test deletes its own product, so there is nothing left here
+		// and no response to check. Asserting 200 unconditionally would pass whether a
+		// delete happened or not.
+		if ( deleteResponse !== null ) {
+			expect( deleteResponse.status ).toBe( 200 );
+			// The batch endpoint reports per-item failures in the body and still
+			// returns 200, so the status on its own says nothing about whether the
+			// products actually went.
+			// An extension filter can reshape these results, so an entry that is missing,
+			// errored or carries no id counts as not deleted rather than throwing here.
+			const batchDeleteResults = deleteResponse.data?.delete;
+			const deleted: Array< { id?: number; error?: unknown } | null > =
+				Array.isArray( batchDeleteResults ) ? batchDeleteResults : [];
+			const confirmedIds = new Set(
+				deleted
+					.filter( ( item ) => item && ! item.error )
+					.map( ( item ) => item?.id )
+			);
+			const unconfirmedIds = idsToDelete.filter(
+				( id ) => ! confirmedIds.has( id )
+			);
+			createdProductIds.splice(
+				0,
+				createdProductIds.length,
+				...unconfirmedIds
+			);
+			expect( deleted ).toHaveLength( idsToDelete.length );
+			expect( unconfirmedIds ).toEqual( [] );
+		}
+		expect( taskListShown ).toBe( true );
+	} );
+
 	test.afterAll( async ( { restApi } ) => {
 		await restApi.post( `${ WC_ADMIN_API_PATH }/onboarding/profile`, {
 			skipped: false,
 		} );
 	} );

-	test( 'Add product task displays options for different product types', async ( {
+	test( 'Products page redirects to add product task when no products exist', async ( {
 		page,
+		restApi,
 	} ) => {
-		// Navigate to the task list
-		await page.goto( 'wp-admin/admin.php?page=wc-admin&task=products' );
+		const productName = `Add product task product ${ Date.now() }`;

-		// Verify product type options are displayed
+		await page.goto( 'wp-admin/admin.php?page=wc-admin&task=products' );
 		await expect(
 			page.getByRole( 'menuitem', { name: 'Physical product' } )
 		).toBeVisible();
@@ -109,23 +165,7 @@ test.describe( 'Add Product Task', () => {
 			/wp-has-current-submenu/
 		);

-		await page
-			.getByRole( 'menuitem', { name: 'Physical product' } )
-			.click();
-		await expect(
-			page.locator(
-				'#menu-posts-product .wp-submenu li.current > a[href="post-new.php?post_type=product"]'
-			)
-		).toBeVisible();
-	} );
-
-	test( 'Products page redirects to add product task when no products exist', async ( {
-		page,
-	} ) => {
-		// Navigate to All Products page
 		await page.goto( 'wp-admin/edit.php?post_type=product' );
-
-		// Verify redirect to add product task
 		await expect( page ).toHaveURL(
 			/.+path=%2Fadd-product.+task=products/
 		);
@@ -152,57 +192,11 @@ test.describe( 'Add Product Task', () => {
 		await expect(
 			page.locator( '#toplevel_page_woocommerce' )
 		).toHaveClass( /wp-has-current-submenu/ );
-	} );
-
-	test( 'Products page shows products table when products exist', async ( {
-		page,
-		restApi,
-	} ) => {
-		// Create a test product
-		await restApi.post( `${ WC_API_PATH }/products`, {
-			name: 'Test Product',
-			type: 'simple',
-			regular_price: '10.00',
-		} );
-
-		// Navigate to All Products page
-		await page.goto( 'wp-admin/edit.php?post_type=product' );
-
-		// Verify products table is visible
-		await expect( page.locator( '.wp-list-table' ) ).toBeVisible();
-		await expect(
-			page.getByRole( 'columnheader', { name: 'Name' } )
-		).toHaveCount( 2 );
-		await expect(
-			page.getByRole( 'columnheader', { name: 'SKU' } )
-		).toHaveCount( 2 );
-		await expect(
-			page.getByRole( 'columnheader', { name: 'Price' } )
-		).toHaveCount( 2 );
-		await expect(
-			page.locator( '.wp-list-table > tbody > tr' )
-		).toHaveCount( 1 );
-
-		// Clean up - delete test product
-		const products = await restApi.get( `${ WC_API_PATH }/products` );
-		for ( const product of products.data ) {
-			await restApi.delete( `${ WC_API_PATH }/products/${ product.id }`, {
-				force: true,
-			} );
-		}
-	} );

-	test( 'Products page redirects to add product task when no products exist and task list is hidden', async ( {
-		page,
-		restApi,
-	} ) => {
-		// Hide the task list
 		expect( await hide_task_list( restApi, 'setup' ) ).toBe( true );

-		// Navigate to All Products page
 		await page.goto( 'wp-admin/edit.php?post_type=product' );

-		// Verify redirect to add product task
 		await expect( page ).toHaveURL(
 			/.+path=%2Fadd-product.+task=products/
 		);
@@ -216,7 +210,51 @@ test.describe( 'Add Product Task', () => {
 			page.getByRole( 'menuitem', { name: 'Grouped product' } )
 		).toBeVisible();

-		// Reset task list to visible
 		expect( await show_task_list( restApi, 'setup' ) ).toBe( true );
+
+		const productResponse = await restApi.post(
+			`${ WC_API_PATH }/products`,
+			{
+				name: productName,
+				type: 'simple',
+				regular_price: '10.00',
+			}
+		);
+		const createdProductId = productResponse.data.id;
+		createdProductIds.push( createdProductId );
+		expect( productResponse.status ).toBe( 201 );
+		expect(
+			Number.isSafeInteger( createdProductId ) && createdProductId > 0
+		).toBe( true );
+
+		await page.goto( 'wp-admin/edit.php?post_type=product' );
+
+		await expect( page.locator( '.wp-list-table' ) ).toBeVisible();
+		await expect(
+			page.getByRole( 'link', { name: productName, exact: true } )
+		).toBeVisible();
+
+		const deleteResponse = await restApi.delete(
+			`${ WC_API_PATH }/products/${ createdProductId }`,
+			{ force: true }
+		);
+		expect( deleteResponse.status ).toBe( 200 );
+		// Stop tracking only once the delete is confirmed. Splicing first means a
+		// failed delete leaves a published product that the afterEach sweep no
+		// longer knows about -- in a spec whose whole premise is an empty catalog.
+		createdProductIds.splice(
+			createdProductIds.indexOf( createdProductId ),
+			1
+		);
+
+		await page.goto( 'wp-admin/admin.php?page=wc-admin&task=products' );
+		await page
+			.getByRole( 'menuitem', { name: 'Physical product' } )
+			.click();
+		await expect(
+			page.locator(
+				'#menu-posts-product .wp-submenu li.current > a[href="post-new.php?post_type=product"]'
+			)
+		).toBeVisible();
 	} );
 } );
diff --git a/plugins/woocommerce/tests/e2e/tests/onboarding/onboarding-wizard.spec.ts b/plugins/woocommerce/tests/e2e/tests/onboarding/onboarding-wizard.spec.ts
index 4dbb6b59e94..35bb6552996 100644
--- a/plugins/woocommerce/tests/e2e/tests/onboarding/onboarding-wizard.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/onboarding/onboarding-wizard.spec.ts
@@ -2,7 +2,7 @@
  * External dependencies
  */
 import { request } from '@playwright/test';
-import type { Page } from '@playwright/test';
+import { WC_ADMIN_API_PATH } from '@woocommerce/e2e-utils-playwright';

 /**
  * Internal dependencies
@@ -12,12 +12,6 @@ import { setOption } from '../../utils/options';
 import { setComingSoon } from '../../utils/coming-soon';
 import { ADMIN_STATE_PATH } from '../../playwright.config';

-const getPluginLocator = ( page: Page, slug: string ) => {
-	return page.locator(
-		`.woocommerce-profiler-plugins-plugin-card[data-slug="${ slug }"]`
-	);
-};
-
 test.use( { storageState: ADMIN_STATE_PATH } );

 test.afterAll( async ( { baseURL } ) => {
@@ -28,7 +22,9 @@ test.describe(
 	'Store owner can complete the core profiler',
 	{ tag: tags.SKIP_ON_EXTERNAL_ENV },
 	() => {
-		test.beforeAll( async ( { baseURL } ) => {
+		// Completing the profiler saves the store location, and the location list leaves out
+		// the selected option. Reset it before every test so each one can pick the same location.
+		test.beforeEach( async ( { baseURL } ) => {
 			try {
 				await setOption(
 					request,
@@ -36,6 +32,12 @@ test.describe(
 					'woocommerce_remote_variant_assignment',
 					'60'
 				);
+				await setOption(
+					request,
+					baseURL,
+					'woocommerce_default_country',
+					'US:CA'
+				);
 			} catch ( error ) {
 				console.log( error );
 			}
@@ -120,12 +122,6 @@ test.describe(
 						name: 'Get a boost with our free features',
 					} )
 				).toBeVisible();
-				// check that WooPayments is displayed because Australia is a supported country
-				await expect(
-					page.getByRole( 'heading', {
-						name: 'Get paid with WooPayments',
-					} )
-				).toBeVisible();
 				// skip this step so that no extensions are installed
 				await page
 					.getByRole( 'button', { name: 'Skip this step' } )
@@ -147,7 +143,8 @@ test.describe(
 				// dashboard shown
 				await expect(
 					page.getByRole( 'heading', {
-						name: 'Welcome to WooCommerce Core E2E Test Suite',
+						name: 'Home',
+						exact: true,
 					} )
 				).toBeVisible();

@@ -202,18 +199,70 @@ test.describe(
 			} );
 		} );

-		test( 'Can complete the core profiler installing default extensions', async ( {
+		test( 'Can complete the core profiler with an extension selected', async ( {
 			page,
+			restApi,
 		} ) => {
 			test.skip(
 				!! process.env.IS_MULTISITE,
 				'Test not working on a multisite setup, see https://github.com/woocommerce/woocommerce/issues/55066'
 			);
+
+			// Installing from WordPress.org would make this title depend on an outside
+			// service and leave real plugins on the shared site, so the two requests the
+			// profiler sends for the selection are answered here with the REST
+			// controller's success shape. What stays under test is the browser half:
+			// the selection reaches those requests, and a successful install finishes
+			// the profiler on the home screen.
+			const slug = 'google-listings-and-ads';
+			const pluginRequests: Array< { path: string; body: unknown } > = [];
+			await page.route(
+				( url ) =>
+					/\/wc-admin\/plugins\/(install|activate)(?:[?&]|$)/.test(
+						decodeURIComponent( url.toString() )
+					),
+				async ( route ) => {
+					const path = /\/wc-admin\/plugins\/activate(?:[?&]|$)/.test(
+						decodeURIComponent( route.request().url() )
+					)
+						? 'activate'
+						: 'install';
+					pluginRequests.push( {
+						path,
+						body: route.request().postDataJSON(),
+					} );
+					const errors = { errors: {}, error_data: {} };
+					await route.fulfill( {
+						json:
+							path === 'install'
+								? {
+										data: {
+											installed: [ slug ],
+											results: { [ slug ]: true },
+											install_time: { [ slug ]: 1 },
+											plugin_details: {},
+										},
+										errors,
+										success: true,
+								  }
+								: {
+										data: {
+											activated: [ slug ],
+											active: [ slug ],
+											plugin_details: {},
+										},
+										errors,
+										success: true,
+								  },
+					} );
+				}
+			);
+
 			await page.goto(
 				'wp-admin/admin.php?page=wc-admin&path=%2Fsetup-wizard'
 			);

-			await test.step( 'Intro page and opt in to data sharing', async () => {
+			await test.step( 'Walk the profiler to the extensions step', async () => {
 				await expect(
 					page.getByRole( 'heading', { name: 'Welcome to Woo!' } )
 				).toBeVisible();
@@ -225,9 +274,7 @@ test.describe(
 				await page
 					.getByRole( 'button', { name: 'Set up my store' } )
 					.click();
-			} );

-			await test.step( 'User profile information', async () => {
 				await expect(
 					page.getByRole( 'heading', {
 						name: 'Which one of these best describes you?',
@@ -235,42 +282,32 @@ test.describe(
 				).toBeVisible();
 				await page
 					.getByRole( 'radio' )
-					.filter( { hasText: 'already selling' } )
-					.click();
-				await page.getByLabel( 'Select an option' ).click();
-				await page
-					.getByRole( 'option' )
-					.filter( { hasText: 'selling offline' } )
+					.filter( { hasText: 'just starting my business' } )
 					.click();
 				await page.getByRole( 'button', { name: 'Continue' } ).click();
-			} );

-			await test.step( 'Business Information', async () => {
 				await expect(
 					page.getByRole( 'heading', {
 						name: 'Tell us a bit about your store',
 					} )
 				).toBeVisible();
-				await expect(
-					page.getByPlaceholder( 'Ex. My awesome store' )
-				).toHaveValue( 'WooCommerce Core E2E Test Suite' );
 				await page
 					.locator(
 						'form.woocommerce-profiler-business-information-form > div > div > div > div > input'
 					)
 					.first()
 					.click();
-				// select food and drink
 				await page
-					.getByRole( 'option', { name: 'Food and drink' } )
+					.getByRole( 'option', { name: 'Clothing and accessories' } )
 					.click();
-				// select a WooPayments incompatible location
+				// The location field is required.
 				await page.getByRole( 'combobox' ).last().click();
-				await page.getByRole( 'combobox' ).last().fill( 'Afghanistan' );
+				await page.getByRole( 'combobox' ).last().fill( 'Australia' );
 				await page
-					.getByRole( 'option', { name: 'Afghanistan' } )
+					.getByRole( 'option', {
+						name: 'Australia — Northern Territory',
+					} )
 					.click();
-
 				await page
 					.getByPlaceholder( 'wordpress@example.com' )
 					.fill( 'merchant@example.com' );
@@ -278,208 +315,57 @@ test.describe(
 				await page.getByRole( 'button', { name: 'Continue' } ).click();
 			} );

-			await test.step( 'Extensions -- install some suggested extensions', async () => {
+			await test.step( 'Select one extension and continue', async () => {
 				await expect(
 					page.getByRole( 'heading', {
 						name: 'Get a boost with our free features',
 					} )
 				).toBeVisible();
-				// check that WooPayments is not displayed because Afghanistan is not a supported country
-				await expect(
-					page.getByRole( 'heading', {
-						name: 'Get paid with WooPayments',
-					} )
-				).not.toBeAttached();
-
-				// select and install the rest of the extensions
-				try {
-					await page
-						.getByText(
-							'Boost content creation with Jetpack AI AssistantSave time on content creation'
-						)
-						.getByRole( 'checkbox' )
-						.uncheck( { timeout: 2000 } );
-				} catch ( e ) {
-					console.log(
-						'Checkbox not present for Jetpack AI Assistant'
-					);
-				}
-				try {
-					await getPluginLocator( page, 'pinterest-for-woocommerce' )
-						.getByRole( 'checkbox' )
-						.check( { timeout: 2000 } );
-				} catch ( e ) {
-					console.log( 'Checkbox not present for Pinterest' );
-				}
-				try {
-					await getPluginLocator( page, 'mailchimp-for-woocommerce' )
-						.getByRole( 'checkbox' )
-						.uncheck( { timeout: 2000 } );
-				} catch ( e ) {
-					console.log( 'Checkbox not present for MailChimp' );
+				// Every recommendation that is not active starts selected. Clear them all,
+				// then pick one that needs no Jetpack connection, so the profiler ends on
+				// the home screen rather than the Jetpack authorization page.
+				const cards = page.locator(
+					'.woocommerce-profiler-plugins-plugin-card'
+				);
+				await expect( cards.first() ).toBeVisible();
+				for ( const checkbox of await cards
+					.getByRole( 'checkbox' )
+					.all() ) {
+					await checkbox.uncheck();
 				}
-				await getPluginLocator( page, 'google-listings-and-ads' )
+				await expect(
+					cards.locator( 'input[type="checkbox"]:checked' )
+				).toHaveCount( 0 );
+				await cards
+					.and( page.locator( `[data-slug="${ slug }"]` ) )
 					.getByRole( 'checkbox' )
-					.check( { timeout: 2000 } );
+					.check();
 				await page.getByRole( 'button', { name: 'Continue' } ).click();
 			} );

-			await test.step( 'Confirm that core profiler was completed and a couple of default extensions installed', async () => {
-				page.on( 'dialog', ( dialog ) => dialog.accept() );
-				// intermediate page shown
-				// the next two are soft assertions because depending on the extensions selected, they may or may not appear
-				// and we want the test to complete in order for cleanup to happen
-				await expect
-					.soft(
-						page
-							.getByRole( 'heading' )
-							.filter( { hasText: 'get your features ready' } )
-					)
-					.toBeVisible( { timeout: 30000 } );
-				await expect
-					.soft(
-						page
-							.getByRole( 'heading' )
-							.filter( { hasText: 'Extending your store' } )
-					)
-					.toBeVisible( { timeout: 30000 } );
-				// dashboard shown
-				await expect(
-					page.getByRole( 'heading', {
-						name: 'Welcome to WooCommerce Core E2E Test Suite',
-					} )
-				).toBeVisible( { timeout: 30000 } );
-				// go to the plugins page to make sure that extensions were installed
-				await page.goto( 'wp-admin/plugins.php?plugin_status=active' );
-				await expect(
-					page.getByRole( 'heading', {
-						name: 'Plugins',
-						exact: true,
-					} )
-				).toBeVisible();
-				// confirm that the optional plugins are present
-				try {
-					await expect(
-						page.locator(
-							`[data-slug="pinterest-for-woocommerce"]`
-						)
-					).toBeVisible();
-				} catch {
-					console.log(
-						`Pinterest is not found or not visible on the page`
-					);
-				}
-
-				try {
-					await expect(
-						page.locator( `[data-slug="google-listings-and-ads"]` )
-					).toBeVisible();
-				} catch {
-					console.log(
-						`Google for WooCommerce is not found or not visible on the page`
-					);
-				}
-
-				try {
-					await expect(
-						page.locator(
-							`[data-slug="mailchimp-for-woocommerce"]`
-						)
-					).toBeHidden();
-				} catch {
-					console.log( `MailChimp is found on the page` );
-				}
-
-				try {
-					await expect(
-						page.locator( `[data-slug="jetpack"]` )
-					).toBeHidden();
-				} catch {
-					console.log( `Jetpack is found on the page` );
-				}
-			} );
-
-			await test.step( 'Confirm that information from core profiler saved', async () => {
-				await page.goto( 'wp-admin/admin.php?page=wc-settings' );
+			await test.step( 'Confirm the selection was installed and the profiler finished', async () => {
 				await expect(
-					page.getByRole( 'textbox', { name: 'Afghanistan' } )
+					page.getByRole( 'heading', { name: 'Home', exact: true } )
 				).toBeVisible();
-				await expect(
-					page.getByRole( 'textbox', { name: 'Afghan afghani (؋)' } )
-				).toBeVisible();
-				await expect(
-					page.getByRole( 'textbox', { name: 'Left with space' } )
-				).toBeVisible();
-				await expect(
-					page.getByLabel( 'Thousand separator', { exact: true } )
-				).toHaveValue( '.' );
-				await expect(
-					page.getByLabel( 'Decimal separator', { exact: true } )
-				).toHaveValue( ',' );
-				await expect(
-					page.getByLabel( 'Number of decimals' )
-				).toHaveValue( '0' );
-			} );
-
-			await test.step( 'Clean up installed extensions', async () => {
-				const deactivateAndDeletePlugin = async ( slug: string ) => {
-					await page.goto( 'wp-admin/plugins.php' );
-					const pluginRow = page.locator(
-						`tr[data-slug="${ slug }"]`
-					);
-
-					// Skip if plugin is not present
-					if ( ! ( await pluginRow.isVisible() ) ) {
-						return;
-					}
-
-					// Deactivate if active
-					const deactivateLink = pluginRow.getByRole( 'link', {
-						name: 'Deactivate',
-						exact: true,
-					} );
-					if ( await deactivateLink.isVisible() ) {
-						await deactivateLink.click();
-						await expect(
-							page.getByText( 'Plugin deactivated.' )
-						).toBeVisible();
-					}

-					// Delete plugin
-					const deleteLink = pluginRow.getByRole( 'link', {
-						name: 'Delete',
-						exact: true,
-					} );
-					if ( await deleteLink.isVisible() ) {
-						try {
-							await deleteLink.click();
-							await expect(
-								page.getByText( 'was successfully deleted.' )
-							).toBeVisible( { timeout: 5000 } );
-						} catch ( e ) {
-							await page
-								.getByText( 'Yes, delete these files and data' )
-								.click();
-							await page
-								.getByText(
-									'The selected plugin has been deleted.'
-								)
-								.waitFor();
-						}
-					}
-				};
-
-				await deactivateAndDeletePlugin( 'google-listings-and-ads' );
-				await deactivateAndDeletePlugin( 'pinterest-for-woocommerce' );
-			} );
-
-			await test.step( 'Confirm that the store is in coming soon mode after completing the core profiler', async () => {
-				await page.goto( 'wp-admin/admin.php?page=wc-admin' );
-				await expect(
-					page
-						.getByRole( 'menuitem' )
-						.filter( { hasText: 'coming soon' } )
-				).toBeVisible();
+				expect( pluginRequests ).toEqual( [
+					{
+						path: 'install',
+						body: {
+							plugins: slug,
+							async: false,
+							source: 'core-profiler',
+						},
+					},
+					{ path: 'activate', body: { plugins: slug } },
+				] );
+
+				// The profiler records what the install reported, which only happens on
+				// the success path: a failed install returns to the extensions step.
+				const { data: profile } = await restApi.get(
+					`${ WC_ADMIN_API_PATH }/onboarding/profile`
+				);
+				expect( profile.business_extensions ).toEqual( [ slug ] );
 			} );
 		} );
 	}
@@ -522,7 +408,8 @@ test.describe(

 			await expect(
 				page.getByRole( 'heading', {
-					name: 'Welcome to WooCommerce Core E2E Test Suite',
+					name: 'Home',
+					exact: true,
 				} )
 			).toBeVisible();

diff --git a/plugins/woocommerce/tests/php/src/Admin/Features/OnboardingTasks/Tasks/ProductsTest.php b/plugins/woocommerce/tests/php/src/Admin/Features/OnboardingTasks/Tasks/ProductsTest.php
new file mode 100644
index 00000000000..72ab4f1176c
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Admin/Features/OnboardingTasks/Tasks/ProductsTest.php
@@ -0,0 +1,188 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Admin\Features\OnboardingTasks\Tasks;
+
+use Automattic\WooCommerce\Admin\Features\OnboardingTasks\Task;
+use Automattic\WooCommerce\Admin\Features\OnboardingTasks\TaskList;
+use Automattic\WooCommerce\Admin\Features\OnboardingTasks\Tasks\Products;
+use WC_Product_Simple;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the Products onboarding task.
+ */
+class ProductsTest extends WC_Unit_Test_Case {
+
+	/**
+	 * The System Under Test.
+	 *
+	 * @var Products
+	 */
+	private $sut;
+
+	/**
+	 * The parent task list used by the Products task.
+	 *
+	 * @var TaskList
+	 */
+	private $task_list;
+
+	/**
+	 * The redirect interceptor registered for the test.
+	 *
+	 * @var callable
+	 */
+	private $redirect_interceptor;
+
+	/**
+	 * The location of an intercepted redirect.
+	 *
+	 * @var string|null
+	 */
+	private $redirect_location;
+
+	/**
+	 * The status of an intercepted redirect.
+	 *
+	 * @var int|null
+	 */
+	private $redirect_status;
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		delete_transient( Products::HAS_PRODUCT_TRANSIENT );
+		delete_option( Task::COMPLETED_OPTION );
+		update_option( TaskList::HIDDEN_OPTION, array( 'setup' ) );
+
+		$this->redirect_location    = null;
+		$this->redirect_status      = null;
+		$this->redirect_interceptor = function ( $location, $status ) {
+			$this->redirect_location = (string) $location;
+			$this->redirect_status   = (int) $status;
+
+			throw new \RuntimeException( 'Redirect intercepted.' );
+		};
+		add_filter( 'wp_redirect', $this->redirect_interceptor, 10, 2 );
+
+		$this->task_list = new TaskList(
+			array(
+				'id'        => 'setup',
+				'hidden_id' => 'setup',
+			)
+		);
+		$this->sut       = new Products( $this->task_list );
+	}
+
+	/**
+	 * @testdox Redirects an empty product list through the registered current_screen hook.
+	 */
+	public function test_redirects_empty_product_list_through_registered_current_screen_hook(): void {
+		$this->trigger_current_screen( 'edit-product' );
+
+		$this->assertSame(
+			admin_url( 'admin.php?page=wc-admin&path=/add-product&task=products' ),
+			$this->redirect_location,
+			'An empty product list should redirect to the Products task.'
+		);
+		$this->assertSame( 302, $this->redirect_status, 'The Products task redirect should use the default 302 status.' );
+	}
+
+	/**
+	 * @testdox Does not redirect non-product screens.
+	 *
+	 * @dataProvider non_product_screen_provider
+	 *
+	 * @param string $screen_id Screen ID to trigger.
+	 */
+	public function test_does_not_redirect_non_product_screens( string $screen_id ): void {
+		$this->trigger_current_screen( $screen_id );
+
+		$this->assertNull( $this->redirect_location, "The {$screen_id} screen should not redirect." );
+		$this->assertNull( $this->redirect_status, "The {$screen_id} screen should not set a redirect status." );
+	}
+
+	/**
+	 * Provides named non-product screens.
+	 *
+	 * @return array<string, array{screen_id: string}>
+	 */
+	public function non_product_screen_provider(): array {
+		return array(
+			'dashboard'         => array( 'screen_id' => 'dashboard' ),
+			'non-product posts' => array( 'screen_id' => 'edit-post' ),
+		);
+	}
+
+	/**
+	 * @testdox Does not redirect the product list when a published product exists.
+	 */
+	public function test_does_not_redirect_product_list_when_published_product_exists(): void {
+		$this->create_product( 'publish' );
+
+		$this->trigger_current_screen( 'edit-product' );
+
+		$this->assertNull( $this->redirect_location, 'A published product should keep the product list accessible.' );
+		$this->assertNull( $this->redirect_status, 'A published product should not produce a redirect status.' );
+	}
+
+	/**
+	 * @testdox Redirects the product list when only an auto-draft product exists.
+	 */
+	public function test_redirects_product_list_when_only_auto_draft_product_exists(): void {
+		$this->create_product( 'auto-draft' );
+
+		$this->trigger_current_screen( 'edit-product' );
+
+		$this->assertSame(
+			admin_url( 'admin.php?page=wc-admin&path=/add-product&task=products' ),
+			$this->redirect_location,
+			'An auto-draft alone should not keep the product list accessible.'
+		);
+		$this->assertSame( 302, $this->redirect_status, 'The auto-draft redirect should use the default 302 status.' );
+	}
+
+	/**
+	 * @testdox Is always accessible when its parent task list is hidden.
+	 */
+	public function test_is_always_accessible_when_parent_task_list_is_hidden(): void {
+		$this->assertTrue( $this->task_list->is_hidden(), 'The Products task parent list should use the persisted hidden-list state.' );
+		$this->assertTrue( $this->sut->is_always_accessible(), 'The Products task should remain accessible when its parent list is hidden.' );
+	}
+
+	/**
+	 * Creates a real simple product with the requested status.
+	 *
+	 * @param string $status Product status.
+	 * @return int Product ID.
+	 */
+	private function create_product( string $status ): int {
+		$product = new WC_Product_Simple();
+		$product->set_name( "Products task {$status} fixture" );
+		$product->set_status( $status );
+		$product_id = $product->save();
+		$this->assertGreaterThan( 0, $product_id, 'The product fixture should persist before the task reads it.' );
+
+		return $product_id;
+	}
+
+	/**
+	 * Triggers the registered current_screen callbacks for a WordPress screen.
+	 *
+	 * @param string $screen_id Screen ID to trigger.
+	 */
+	private function trigger_current_screen( string $screen_id ): void {
+		try {
+			set_current_screen( $screen_id );
+		} catch ( \RuntimeException $exception ) {
+			if ( 'Redirect intercepted.' !== $exception->getMessage() ) {
+				throw $exception;
+			}
+		}
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Admin/RemoteFreeExtensions/DefaultFreeExtensionsTest.php b/plugins/woocommerce/tests/php/src/Internal/Admin/RemoteFreeExtensions/DefaultFreeExtensionsTest.php
index bc42af76103..dd20fbb08ca 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Admin/RemoteFreeExtensions/DefaultFreeExtensionsTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Admin/RemoteFreeExtensions/DefaultFreeExtensionsTest.php
@@ -161,6 +161,53 @@ class DefaultFreeExtensionsTest extends WC_Unit_Test_Case {
 		);
 	}

+	/**
+	 * @testdox Core profiler WooPayments visibility should follow the store country.
+	 * @dataProvider core_profiler_woocommerce_payments_visibility_provider
+	 *
+	 * @param string $country        Store country and optional state.
+	 * @param bool   $should_include Whether WooPayments should be recommended.
+	 */
+	public function test_core_profiler_woocommerce_payments_visibility_by_country( string $country, bool $should_include ): void {
+		update_option( 'woocommerce_default_country', $country );
+		update_option( 'woocommerce_store_address', '1 Test Street' );
+		update_option( 'woocommerce_remote_variant_assignment', 60 );
+		update_option( 'active_plugins', array() );
+		update_option( 'woocommerce_onboarding_profile', array() );
+
+		$results = EvaluateExtension::evaluate_bundles(
+			DefaultFreeExtensions::get_all(),
+			array( 'obw/core-profiler' )
+		);
+
+		$this->assertSame( array(), $results['errors'], 'The real core profiler bundle should evaluate without errors.' );
+		$this->assertCount( 1, $results['bundles'], 'Only the core profiler bundle should be evaluated.' );
+		$plugin_slugs = array_map(
+			static function ( $plugin ) {
+				return $plugin->key;
+			},
+			$results['bundles'][0]['plugins']
+		);
+
+		if ( $should_include ) {
+			$this->assertContains( 'woocommerce-payments', $plugin_slugs );
+		} else {
+			$this->assertNotContains( 'woocommerce-payments', $plugin_slugs );
+		}
+	}
+
+	/**
+	 * Store countries for core profiler WooPayments visibility.
+	 *
+	 * @return array<string, array{string, bool}>
+	 */
+	public function core_profiler_woocommerce_payments_visibility_provider(): array {
+		return array(
+			'AU:NT' => array( 'AU:NT', true ),
+			'AF'    => array( 'AF', false ),
+		);
+	}
+
 	/**
 	 * Evaluates bundles passed as argument and extracts keys of recommended plugins.
 	 *