Commit 1631d616d32 for woocommerce

commit 1631d616d323f6faee3449613d2eb933dedc622e
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date:   Mon Oct 5 18:02:56 2026 +0300

    Keep full extension keys in the Core Profiler selection so `:alt` extensions are not lost (#69106)

    * fix(core-profiler): Keep full plugin keys in the selection state

    The Core Profiler Plugins page removed the ':alt' suffix from selected
    plugin keys before storing them as pluginsSelected. That dates from
    the first version of the page, which sent its list straight to the
    installer. The installer now converts keys to slugs itself with
    getPluginSlug, so the early strip no longer serves installation.

    Five readers of the stored selection look plugins up by their full
    key, so an ':alt'-keyed plugin was lost in each of them:
    - the retry selection after an install error came back unchecked;
    - the error banner showed the slug instead of the plugin name;
    - the Jetpack connection prefetch did not fire for a plugin that
      needs it;
    - the Jetpack auth URL left the plugin out of plugin_name;
    - the install order ignored the plugin's install_priority.

    Store full keys instead, and remove ':alt' only at the outputs that
    need a slug: the coreprofiler_store_extensions_continue Tracks
    'selected' list, the business_extensions profile item, and the
    Jetpack plugin_name parameter. Their values are unchanged from
    before. pluginsShown and pluginsUnselected still carry slugs.

    No bundle the profiler loads uses an ':alt' key today, so this fixes
    a latent mismatch rather than a visible one.

    Refs WOOAIRR-354

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * fix: Fix alternate plugin installation timing

    The core profiler retains full extension keys so alternate selections
    survive installation retries. Installation timing responses still use
    plugin slugs, causing alternate keys to record a zero duration.

    Use the existing slug helper when reading installation timing while
    preserving the full extension key in results. Add a regression test
    that verifies a 4000 ms response for mailpoet:alt retains its duration.

    * fix: Preserve Tax installation timing telemetry

    Normalizing all plugin suffixes also corrected the existing Tax timing
    lookup, changing its Tracks duration buckets compared with trunk.

    Strip only :alt when reading installation timing, matching the original
    selection normalization. Keep the Tax correction out of this PR so it
    can be reviewed separately for telemetry impact. Cover both alternate
    plugin timing and the existing Tax fallback in the installer test.

    * fix: Correct plugin installation timing response type

    The timing lookup normalizes alternate extension keys to strings, but
    the response type only allowed the fixed PluginNames union. This caused
    TS7053 in the admin package type check despite passing ESLint.

    Model installation timing as a sparse map of plugin slugs to durations.
    This permits normalized lookups and represents missing timing entries
    without changing the runtime lookup or telemetry behavior.

    ---------

    Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
    Co-authored-by: Oleksandr Aratovskyi <79862886+oaratovskyi@users.noreply.github.com>

diff --git a/plugins/woocommerce/changelog/wooairr-354-core-profiler-alt-keys b/plugins/woocommerce/changelog/wooairr-354-core-profiler-alt-keys
new file mode 100644
index 00000000000..3ddf362f6d3
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooairr-354-core-profiler-alt-keys
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Keep recommended extensions selected, named, and correctly ordered when the onboarding wizard installs or retries them.
diff --git a/plugins/woocommerce/client/admin/client/core-profiler/actions/test/tracks.test.ts b/plugins/woocommerce/client/admin/client/core-profiler/actions/test/tracks.test.ts
index c2a4222f726..c708258948d 100644
--- a/plugins/woocommerce/client/admin/client/core-profiler/actions/test/tracks.test.ts
+++ b/plugins/woocommerce/client/admin/client/core-profiler/actions/test/tracks.test.ts
@@ -141,6 +141,37 @@ describe( 'Core Profiler shipping partner tracking', () => {
 		} );
 	} );

+	describe( 'recordTracksPluginsInstallationRequest (coreprofiler_store_extensions_continue)', () => {
+		it( 'should report selected :alt plugins by slug', () => {
+			tracksActions.recordTracksPluginsInstallationRequest( {
+				context: makeContext(),
+				event: {
+					type: 'PLUGINS_INSTALLATION_REQUESTED',
+					payload: {
+						pluginsShown: [
+							'mailpoet',
+							'woocommerce-services:tax',
+						],
+						pluginsSelected: [
+							'mailpoet:alt',
+							'woocommerce-services:tax',
+						],
+						pluginsUnselected: [],
+					},
+				},
+			} );
+
+			expect( recordEvent ).toHaveBeenCalledWith(
+				'coreprofiler_store_extensions_continue',
+				{
+					shown: [ 'mailpoet', 'woocommerce-services:tax' ],
+					selected: [ 'mailpoet', 'woocommerce-services:tax' ],
+					unselected: [],
+				}
+			);
+		} );
+	} );
+
 	describe( 'recordTracksPluginsInstallationRequest (shipping_partner_click)', () => {
 		it( 'should fire shipping_partner_click for each selected shipping plugin', () => {
 			const context = makeContext( {
diff --git a/plugins/woocommerce/client/admin/client/core-profiler/actions/tracks.tsx b/plugins/woocommerce/client/admin/client/core-profiler/actions/tracks.tsx
index ffc635e4230..dd7d3d382c0 100644
--- a/plugins/woocommerce/client/admin/client/core-profiler/actions/tracks.tsx
+++ b/plugins/woocommerce/client/admin/client/core-profiler/actions/tracks.tsx
@@ -195,7 +195,9 @@ const recordTracksPluginsInstallationRequest = ( {
 } ) => {
 	recordEvent( 'coreprofiler_store_extensions_continue', {
 		shown: event.payload.pluginsShown || [],
-		selected: event.payload.pluginsSelected || [],
+		selected: ( event.payload.pluginsSelected || [] ).map( ( key ) =>
+			key.replace( ':alt', '' )
+		),
 		unselected: event.payload.pluginsUnselected || [],
 	} );

diff --git a/plugins/woocommerce/client/admin/client/core-profiler/index.tsx b/plugins/woocommerce/client/admin/client/core-profiler/index.tsx
index 3dfcf425885..4675c2e8c40 100644
--- a/plugins/woocommerce/client/admin/client/core-profiler/index.tsx
+++ b/plugins/woocommerce/client/admin/client/core-profiler/index.tsx
@@ -339,7 +339,9 @@ const getPluginNameParam = (
 		} )?.requires_jpc;
 	} );

-	return JpcRequiredPlugins.join( ',' );
+	return JpcRequiredPlugins.map( ( key ) => key.replace( ':alt', '' ) ).join(
+		','
+	);
 };

 const redirectToJetpackAuthPage = ( {
@@ -1577,7 +1579,11 @@ export const coreProfilerStateMachineDefinition = createMachine( {
 												event.payload.installationCompletedResult.installedPlugins.map(
 													(
 														extension: InstalledPlugin
-													) => extension.plugin
+													) =>
+														extension.plugin.replace(
+															':alt',
+															''
+														)
 												),
 											completed: true,
 										} );
diff --git a/plugins/woocommerce/client/admin/client/core-profiler/pages/Plugins/Plugins.tsx b/plugins/woocommerce/client/admin/client/core-profiler/pages/Plugins/Plugins.tsx
index ac353517155..cbe6a19c878 100644
--- a/plugins/woocommerce/client/admin/client/core-profiler/pages/Plugins/Plugins.tsx
+++ b/plugins/woocommerce/client/admin/client/core-profiler/pages/Plugins/Plugins.tsx
@@ -61,8 +61,8 @@ export const computePluginsSelection = (
 	availablePlugins: Extension[],
 	selectedPlugins: Set< Extension >
 ) => {
-	const selectedPluginSlugs = Array.from( selectedPlugins ).map( ( plugin ) =>
-		plugin.key.replace( ':alt', '' )
+	const selectedPluginKeys = Array.from( selectedPlugins ).map(
+		( plugin ) => plugin.key
 	);

 	const pluginsShown: string[] = [];
@@ -74,13 +74,13 @@ export const computePluginsSelection = (

 		if (
 			! plugin.is_activated &&
-			! selectedPluginSlugs.includes( pluginSlug )
+			! selectedPluginKeys.includes( plugin.key )
 		) {
 			pluginsUnselected.push( pluginSlug );
 		}
 	} );

-	return { pluginsShown, pluginsUnselected, selectedPluginSlugs };
+	return { pluginsShown, pluginsUnselected, selectedPluginKeys };
 };

 export const Plugins = ( {
@@ -136,7 +136,7 @@ export const Plugins = ( {
 	};

 	const submitInstallationRequest = () => {
-		const { pluginsShown, pluginsUnselected, selectedPluginSlugs } =
+		const { pluginsShown, pluginsUnselected, selectedPluginKeys } =
 			computePluginsSelection(
 				context.pluginsAvailable,
 				selectedPlugins
@@ -146,7 +146,7 @@ export const Plugins = ( {
 			type: 'PLUGINS_INSTALLATION_REQUESTED',
 			payload: {
 				pluginsShown,
-				pluginsSelected: selectedPluginSlugs,
+				pluginsSelected: selectedPluginKeys,
 				pluginsUnselected,
 			},
 		} );
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..77fc6bbcf82 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
@@ -9,6 +9,19 @@ import { Extension } from '@woocommerce/data';
  */
 import { computePluginsSelection, joinWithAnd, Plugins } from '../Plugins';

+const installationError = ( plugin: string ) => ( {
+	plugin,
+	error: 'Installation failed',
+	errorDetails: {
+		data: {
+			code: 'some_error_code',
+			data: {
+				status: 400,
+			},
+		},
+	},
+} );
+
 describe( 'Plugins Component', () => {
 	const mockSendEvent = jest.fn();
 	const mockContext = {
@@ -66,6 +79,13 @@ describe( 'Plugins Component', () => {
 		pluginsInstallationErrors: [],
 	};
 	const navigationProgress = 80;
+	const altPlugin = {
+		...mockContext.pluginsAvailable[ 3 ],
+		key: 'plugin5:alt',
+		slug: 'plugin5',
+		name: 'Plugin 5',
+		label: 'Plugin 5',
+	};

 	it( 'renders correctly', () => {
 		render(
@@ -211,6 +231,72 @@ describe( 'Plugins Component', () => {
 		expect( checkbox4 ).toBeChecked();
 	} );

+	it( 'keeps an :alt-keyed plugin selected when retrying after an installation error', () => {
+		const context = {
+			...mockContext,
+			pluginsAvailable: [ ...mockContext.pluginsAvailable, altPlugin ],
+		};
+		const { unmount } = render(
+			<Plugins
+				context={ context }
+				sendEvent={ mockSendEvent }
+				navigationProgress={ navigationProgress }
+			/>
+		);
+		fireEvent.click( screen.getByText( 'Continue' ) );
+		const { pluginsSelected } =
+			mockSendEvent.mock.calls[ mockSendEvent.mock.calls.length - 1 ][ 0 ]
+				.payload;
+		unmount();
+
+		// The state machine stores the submitted selection and returns to this page on errors.
+		render(
+			<Plugins
+				context={ {
+					...context,
+					pluginsSelected,
+					pluginsInstallationErrors: [
+						installationError( 'plugin5:alt' ),
+					],
+				} }
+				sendEvent={ mockSendEvent }
+				navigationProgress={ navigationProgress }
+			/>
+		);
+		const checkbox5 = screen
+			.getByRole( 'heading', { level: 3, name: 'Plugin 5' } )
+			.closest( '.woocommerce-profiler-plugins-plugin-card' )
+			?.querySelector( 'input[type="checkbox"]' );
+		expect( checkbox5 ).toBeChecked();
+	} );
+
+	it( 'names an :alt-keyed plugin in the installation error banner', () => {
+		render(
+			<Plugins
+				context={ {
+					...mockContext,
+					pluginsAvailable: [
+						...mockContext.pluginsAvailable,
+						altPlugin,
+					],
+					pluginsInstallationErrors: [
+						installationError( 'plugin5:alt' ),
+					],
+					pluginsSelected: [ 'plugin5:alt' ],
+				} }
+				sendEvent={ mockSendEvent }
+				navigationProgress={ navigationProgress }
+			/>
+		);
+		expect(
+			screen.getByText(
+				/Oops! We encountered a problem while installing/
+			)
+		).toHaveTextContent(
+			'Oops! We encountered a problem while installing Plugin 5.'
+		);
+	} );
+
 	it( 'handles skip action', () => {
 		render(
 			<Plugins
@@ -244,7 +330,7 @@ describe( 'computePluginsSelection', () => {
 		expect( result ).toEqual( {
 			pluginsShown: [ 'plugin1', 'plugin2', 'plugin3' ],
 			pluginsUnselected: [ 'plugin1', 'plugin3' ],
-			selectedPluginSlugs: [],
+			selectedPluginKeys: [],
 		} );
 	} );

@@ -261,7 +347,7 @@ describe( 'computePluginsSelection', () => {
 		expect( result ).toEqual( {
 			pluginsShown: [ 'plugin1', 'plugin2', 'plugin3' ],
 			pluginsUnselected: [],
-			selectedPluginSlugs: [ 'plugin1', 'plugin3' ],
+			selectedPluginKeys: [ 'plugin1', 'plugin3' ],
 		} );
 	} );

@@ -277,7 +363,33 @@ describe( 'computePluginsSelection', () => {
 		expect( result ).toEqual( {
 			pluginsShown: [ 'plugin1', 'plugin2', 'plugin3' ],
 			pluginsUnselected: [ 'plugin3' ],
-			selectedPluginSlugs: [ 'plugin1' ],
+			selectedPluginKeys: [ 'plugin1' ],
+		} );
+	} );
+
+	it( 'keeps the full key of a selected :alt plugin and reports its slug to Tracks', () => {
+		const selectedPlugins = new Set< Extension >( [
+			{ key: 'plugin4:alt' } as Extension,
+		] );
+		const result = computePluginsSelection(
+			[
+				...mockPluginsAvailable,
+				{ key: 'plugin4:alt', is_activated: false },
+				{ key: 'plugin5:alt', is_activated: false },
+			] as Extension[],
+			selectedPlugins
+		);
+
+		expect( result ).toEqual( {
+			pluginsShown: [
+				'plugin1',
+				'plugin2',
+				'plugin3',
+				'plugin4',
+				'plugin5',
+			],
+			pluginsUnselected: [ 'plugin1', 'plugin3', 'plugin5' ],
+			selectedPluginKeys: [ 'plugin4:alt' ],
 		} );
 	} );

@@ -288,7 +400,7 @@ describe( 'computePluginsSelection', () => {
 		expect( result ).toEqual( {
 			pluginsShown: [],
 			pluginsUnselected: [],
-			selectedPluginSlugs: [],
+			selectedPluginKeys: [],
 		} );
 	} );
 } );
diff --git a/plugins/woocommerce/client/admin/client/core-profiler/services/installAndActivatePlugins.ts b/plugins/woocommerce/client/admin/client/core-profiler/services/installAndActivatePlugins.ts
index ac98f852c0b..54eaee12c07 100644
--- a/plugins/woocommerce/client/admin/client/core-profiler/services/installAndActivatePlugins.ts
+++ b/plugins/woocommerce/client/admin/client/core-profiler/services/installAndActivatePlugins.ts
@@ -95,7 +95,7 @@ type InstallAndActivateSuccessResponse = {
 	data: {
 		installed: PluginNames[];
 		results: Record< PluginNames, boolean >;
-		install_time: Record< PluginNames, number >;
+		install_time: Partial< Record< string, number > >;
 	};
 };

@@ -245,7 +245,9 @@ export const pluginInstallerMachine = createMachine(
 							installTime:
 								(
 									event as DoneActorEvent< InstallAndActivateSuccessResponse >
-								 ).output.data.install_time[ plugin ] || 0,
+								 ).output.data.install_time[
+									plugin.replace( ':alt', '' )
+								] || 0,
 						},
 					];
 				},
diff --git a/plugins/woocommerce/client/admin/client/core-profiler/services/test/installAndActivatePlugins.test.ts b/plugins/woocommerce/client/admin/client/core-profiler/services/test/installAndActivatePlugins.test.ts
index cbe4b94a288..a9be52d58db 100644
--- a/plugins/woocommerce/client/admin/client/core-profiler/services/test/installAndActivatePlugins.test.ts
+++ b/plugins/woocommerce/client/admin/client/core-profiler/services/test/installAndActivatePlugins.test.ts
@@ -27,6 +27,43 @@ describe( 'pluginInstallerMachine', () => {
 		jest.resetAllMocks();
 	} );

+	it.each( [
+		[ 'mailpoet:alt', 'mailpoet', 4000 ],
+		[ 'woocommerce-services:tax', 'woocommerce-services', 0 ],
+	] )(
+		'preserves trunk installation timing for %s',
+		async ( plugin, slug, installTime ) => {
+			const machineUnderTest = pluginInstallerMachine.provide( {
+				...mockConfig,
+				actors: {
+					installPlugin: fromPromise( async () => ( {
+						data: {
+							install_time: { [ slug ]: 4000 },
+						},
+					} ) ),
+				},
+			} );
+			const service = createActor( machineUnderTest, {
+				input: {
+					selectedPlugins: [ plugin ],
+					pluginsAvailable: [],
+				},
+			} ).start();
+
+			try {
+				const snapshot = await waitFor( service, ( snap ) =>
+					snap.matches( 'reportSuccess' )
+				);
+
+				expect( snapshot.context.installedPlugins ).toEqual( [
+					{ plugin, installTime },
+				] );
+			} finally {
+				service.stop();
+			}
+		}
+	);
+
 	it( 'when given one plugin it should call the installPlugin service once', async () => {
 		const mockInstallPlugin = jest.fn();
 		mockInstallPlugin.mockResolvedValueOnce( {