Commit bf8b19eb1a9 for woocommerce

commit bf8b19eb1a9f5c0048426ee7860ccbe685c64a10
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date:   Wed Sep 16 00:01:33 2026 +0300

    [tests] Move command palette registration below E2E (#68620)

    * test(admin): Move command palette registration below E2E

    The command palette spec ran six browser titles. Four of them
    opened the palette, typed a command name, clicked the option and
    checked the heading of the page it landed on. What each command
    registers — its name, its label, its icon, and the URL its callback
    navigates to — is decided in the admin bundle's own JavaScript.

    Add Jest tests for that: one file covers the four static WooCommerce
    commands and the product loader, the other the analytics commands
    registered per report. Together they assert each command's name,
    label, icon, tracking event and target URL, and the loader's search
    debounce, its query, and the records it maps.

    Keep two browser titles: the Add new product command, which proves a
    registered command still reaches its destination through the real
    palette, and the product search command, which proves the loader's
    asynchronous path end to end.

    Consolidates the mega-branch slice:
    - Slice 073: test(admin): Move command palette below E2E

    Refs TESTOPS-288
    Refs #68046

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

    * test(admin): Guard the Analytics report handoff to the command palette

    The Jest suite for the Analytics command palette assigns
    window.wcCommandPaletteAnalytics itself, so once the E2E title was
    removed nothing proved that enqueue_command_palette_assets() enqueues
    the analytics bundle and localizes it from get_report_pages(). The
    review asked for a PHPUnit case to close that gap.

    Feed get_report_pages() two reports through its own
    woocommerce_analytics_report_menu_items filter, run the enqueue, and
    decode the localized global. Assert that the bundle is enqueued, that
    reports reach JS as an array, that titles lose their HTML and that
    paths pass through. A fixture keeps the test stable when a report is
    added to Analytics.

    The CI PHPUnit jobs do not build the admin bundles, so enqueue_script()
    throws there for want of an asset registry. When a bundle is not built,
    the test writes a stub .asset.php and tearDown removes it. Six source
    mutations each fail their own assertion, and the class passes with
    assets/client/admin hidden.

    Refs TESTOPS-288

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

    * test(admin): Check an Analytics command reaches the real palette

    CodeRabbit asked to restore the removed "can use an analytics
    command" E2E title. PHPUnit covers what PHP hands the analytics
    bundle and Jest covers what the bundle registers, but nothing proved
    that the built bundle runs in a browser and registers its commands
    into the real palette.

    Rather than bring back a title with its own page load, have the
    retained "Add new product" title search for "WooCommerce Analytics:
    Products" in the open palette and require that option before it
    selects its own command. The palette helper is split into open, find
    and click steps so both searches share one palette.

    With the built command-palette-analytics.js hidden, the title fails
    at that option while the product search title still passes.

    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-command-palette b/plugins/woocommerce/changelog/testops-288-command-palette
new file mode 100644
index 00000000000..9291ac4fef2
--- /dev/null
+++ b/plugins/woocommerce/changelog/testops-288-command-palette
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+Comment: Move command palette command registration below E2E: Jest owns each command's name, label, icon, tracking event and target URL, and the spec keeps two browser titles.
+
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/command-palette-analytics/__tests__/index.test.js b/plugins/woocommerce/client/admin/client/wp-admin-scripts/command-palette-analytics/__tests__/index.test.js
new file mode 100644
index 00000000000..27334f56ca8
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/command-palette-analytics/__tests__/index.test.js
@@ -0,0 +1,108 @@
+/**
+ * External dependencies
+ */
+import { queueRecordEvent } from '@woocommerce/tracks';
+// eslint-disable-next-line import/no-unresolved -- Provided by WordPress in the wp-admin runtime.
+import { store as commandsStore } from '@wordpress/commands';
+import { dispatch } from '@wordpress/data';
+import domReady from '@wordpress/dom-ready';
+import { chartBar } from '@wordpress/icons';
+import { addQueryArgs } from '@wordpress/url';
+
+jest.mock( '@woocommerce/tracks', () => ( { queueRecordEvent: jest.fn() } ) );
+jest.mock( '@wordpress/commands', () => ( { store: 'commands-store' } ) );
+jest.mock( '@wordpress/data', () => ( { dispatch: jest.fn() } ) );
+jest.mock( '@wordpress/dom-ready', () => jest.fn() );
+jest.mock( '@wordpress/icons', () => ( { chartBar: 'chart-bar-icon' } ) );
+jest.mock( '@wordpress/i18n', () => ( {
+	__: ( value ) => value,
+	sprintf: ( format, value ) => format.replace( '%s', value ),
+} ) );
+jest.mock( '@wordpress/url', () => ( {
+	addQueryArgs: jest.fn(
+		( base, args ) => `#${ base }-${ JSON.stringify( args ) }`
+	),
+} ) );
+
+const registerWithReports = ( analytics ) => {
+	const commands = [];
+	dispatch.mockReturnValue( {
+		registerCommand: ( command ) => commands.push( command ),
+	} );
+	if ( analytics === undefined ) {
+		delete window.wcCommandPaletteAnalytics;
+	} else {
+		window.wcCommandPaletteAnalytics = analytics;
+	}
+	jest.isolateModules( () => {
+		// eslint-disable-next-line @typescript-eslint/no-require-imports -- Load after each injected report state is installed.
+		require( '../index' );
+	} );
+	domReady.mock.calls[ 0 ][ 0 ]();
+	return commands;
+};
+
+describe( 'Analytics Command Palette', () => {
+	beforeEach( () => {
+		jest.clearAllMocks();
+	} );
+
+	it.each( [
+		{ caseName: 'missing global', analytics: undefined },
+		{ caseName: 'missing reports', analytics: {} },
+		{ caseName: 'non-array reports', analytics: { reports: {} } },
+		{ caseName: 'empty reports', analytics: { reports: [] } },
+	] )(
+		'does not register commands for an invalid injected report state: $caseName',
+		( { analytics } ) => {
+			expect( registerWithReports( analytics ) ).toEqual( [] );
+			expect( dispatch ).not.toHaveBeenCalled();
+		}
+	);
+
+	it( 'registers injected Analytics reports with exact destinations and tracking', () => {
+		const commands = registerWithReports( {
+			reports: [
+				{ title: 'Revenue', path: '/analytics/revenue' },
+				{ title: 'Orders', path: '/analytics/orders' },
+			],
+		} );
+
+		expect( dispatch ).toHaveBeenCalledWith( commandsStore );
+		expect(
+			commands.map( ( command ) => ( {
+				name: command.name,
+				label: command.label,
+				icon: command.icon,
+			} ) )
+		).toEqual( [
+			{
+				name: 'woocommerce/analytics/revenue',
+				label: 'WooCommerce Analytics: Revenue',
+				icon: chartBar,
+			},
+			{
+				name: 'woocommerce/analytics/orders',
+				label: 'WooCommerce Analytics: Orders',
+				icon: chartBar,
+			},
+		] );
+
+		commands.forEach( ( command, index ) => {
+			command.callback();
+			expect( decodeURIComponent( window.location.hash ) ).toBe(
+				addQueryArgs.mock.results[ index ].value
+			);
+		} );
+		expect( addQueryArgs.mock.calls ).toEqual( [
+			[ 'admin.php', { page: 'wc-admin', path: '/analytics/revenue' } ],
+			[ 'admin.php', { page: 'wc-admin', path: '/analytics/orders' } ],
+		] );
+		expect( queueRecordEvent.mock.calls ).toEqual(
+			commands.map( ( command ) => [
+				'woocommerce_command_palette_submit',
+				{ name: command.name, origin: undefined },
+			] )
+		);
+	} );
+} );
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/command-palette/__tests__/index.test.js b/plugins/woocommerce/client/admin/client/wp-admin-scripts/command-palette/__tests__/index.test.js
new file mode 100644
index 00000000000..7d9342b061c
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/command-palette/__tests__/index.test.js
@@ -0,0 +1,262 @@
+/**
+ * External dependencies
+ */
+import { renderHook } from '@testing-library/react';
+import { queueRecordEvent, recordEvent } from '@woocommerce/tracks';
+// eslint-disable-next-line import/no-unresolved -- Provided by WordPress in the wp-admin runtime.
+import { store as commandsStore } from '@wordpress/commands';
+import { dispatch, useSelect } from '@wordpress/data';
+import domReady from '@wordpress/dom-ready';
+import { box, plus } from '@wordpress/icons';
+import { addQueryArgs } from '@wordpress/url';
+
+/**
+ * Internal dependencies
+ */
+import { registerCommandWithTracking } from '../register-command-with-tracking';
+
+jest.mock( '@woocommerce/tracks', () => ( {
+	queueRecordEvent: jest.fn(),
+	recordEvent: jest.fn(),
+} ) );
+jest.mock( '@wordpress/commands', () => ( { store: 'commands-store' } ) );
+jest.mock( '@wordpress/core-data', () => ( { store: 'core-store' } ) );
+jest.mock( '@wordpress/data', () => ( {
+	dispatch: jest.fn(),
+	useSelect: jest.fn(),
+} ) );
+jest.mock( '@wordpress/dom-ready', () => jest.fn() );
+jest.mock( '@wordpress/html-entities', () => ( {
+	decodeEntities: ( value ) => value.replace( '&amp;', '&' ),
+} ) );
+jest.mock( '@wordpress/i18n', () => ( { __: ( value ) => value } ) );
+jest.mock( '@wordpress/icons', () => ( {
+	box: 'box-icon',
+	plus: 'plus-icon',
+} ) );
+jest.mock( '@wordpress/url', () => ( {
+	addQueryArgs: jest.fn(
+		( base, args ) => `#${ base }-${ JSON.stringify( args ) }`
+	),
+} ) );
+
+describe( 'registerCommandWithTracking', () => {
+	it( 'forwards callback arguments exactly once', () => {
+		jest.clearAllMocks();
+		const registerCommand = jest.fn();
+		dispatch.mockReturnValue( { registerCommand } );
+		const callback = jest.fn();
+		const firstArgument = { sentinel: 'first' };
+		const secondArgument = { sentinel: 'second' };
+
+		registerCommandWithTracking( {
+			name: 'woocommerce/test-command',
+			label: 'Test command',
+			icon: 'test-icon',
+			callback,
+		} );
+
+		const registeredCallback =
+			registerCommand.mock.calls[ 0 ][ 0 ].callback;
+		registeredCallback( firstArgument, secondArgument );
+
+		expect( callback ).toHaveBeenCalledTimes( 1 );
+		expect( callback ).toHaveBeenCalledWith(
+			firstArgument,
+			secondArgument
+		);
+	} );
+} );
+
+describe( 'Command Palette', () => {
+	let registeredCommands;
+	let registeredLoader;
+	let startEntry;
+
+	const runEntry = () => {
+		const commandDispatcher = {
+			registerCommand: ( command ) => registeredCommands.push( command ),
+			registerCommandLoader: ( loader ) => {
+				registeredLoader = loader;
+			},
+		};
+		dispatch.mockReturnValue( commandDispatcher );
+		startEntry();
+	};
+
+	beforeAll( () => {
+		// eslint-disable-next-line @typescript-eslint/no-require-imports -- Load only after the entry-point mocks are installed.
+		require( '../index' );
+		startEntry = domReady.mock.calls[ 0 ][ 0 ];
+	} );
+
+	beforeEach( () => {
+		jest.clearAllMocks();
+		registeredCommands = [];
+		registeredLoader = undefined;
+		runEntry();
+	} );
+
+	afterEach( () => {
+		jest.useRealTimers();
+	} );
+
+	it( 'registers static commands and product loader with exact behavior', () => {
+		expect( dispatch ).toHaveBeenLastCalledWith( commandsStore );
+		expect(
+			registeredCommands.map( ( command ) => ( {
+				name: command.name,
+				label: command.label,
+				icon: command.icon,
+			} ) )
+		).toEqual( [
+			{
+				name: 'woocommerce/add-new-product',
+				label: 'Add new product',
+				icon: plus,
+			},
+			{
+				name: 'woocommerce/add-new-order',
+				label: 'Add new order',
+				icon: plus,
+			},
+			{
+				name: 'woocommerce/view-products',
+				label: 'Products',
+				icon: box,
+			},
+			{
+				name: 'woocommerce/view-orders',
+				label: 'Orders',
+				icon: box,
+			},
+		] );
+		expect( registeredLoader.name ).toBe( 'woocommerce/product' );
+
+		const destinations = [
+			[ 'post-new.php', { post_type: 'product' } ],
+			[ 'admin.php', { page: 'wc-orders', action: 'new' } ],
+			[ 'edit.php', { post_type: 'product' } ],
+			[ 'admin.php', { page: 'wc-orders' } ],
+		];
+		registeredCommands.forEach( ( command, index ) => {
+			command.callback();
+			expect( decodeURIComponent( window.location.hash ) ).toBe(
+				addQueryArgs.mock.results[ index ].value
+			);
+		} );
+
+		expect( addQueryArgs.mock.calls ).toEqual( destinations );
+		expect( queueRecordEvent.mock.calls ).toEqual(
+			registeredCommands.map( ( command ) => [
+				'woocommerce_command_palette_submit',
+				{ name: command.name, origin: undefined },
+			] )
+		);
+	} );
+
+	it( 'loads products, tracks searches, and navigates through product commands', () => {
+		jest.useFakeTimers();
+		const state = { records: undefined, isLoading: true };
+		const getEntityRecords = jest.fn( () => state.records );
+		const hasFinishedResolution = jest.fn( () => ! state.isLoading );
+		useSelect.mockImplementation( ( callback ) =>
+			callback( () => ( { getEntityRecords, hasFinishedResolution } ) )
+		);
+
+		const { result, rerender, unmount } = renderHook(
+			( { search } ) => registeredLoader.hook( { search } ),
+			{ initialProps: { search: '' } }
+		);
+
+		expect( getEntityRecords ).toHaveBeenLastCalledWith(
+			'postType',
+			'product',
+			{
+				search: undefined,
+				per_page: 10,
+				orderby: 'date',
+				status: [ 'publish', 'future', 'draft', 'pending', 'private' ],
+			}
+		);
+		expect( result.current ).toEqual( { commands: [], isLoading: true } );
+
+		state.records = [
+			{ id: 12, title: { rendered: 'Bread &amp; Butter' } },
+			{ id: 13, title: {} },
+		];
+		state.isLoading = false;
+		rerender( { search: 'bread' } );
+
+		expect( getEntityRecords ).toHaveBeenLastCalledWith(
+			'postType',
+			'product',
+			{
+				search: 'bread',
+				per_page: 10,
+				orderby: 'relevance',
+				status: [ 'publish', 'future', 'draft', 'pending', 'private' ],
+			}
+		);
+		expect( result.current ).toMatchObject( {
+			isLoading: false,
+			commands: [
+				{
+					name: 'product-12',
+					searchLabel: 'Bread &amp; Butter 12',
+					label: 'Bread & Butter',
+					icon: box,
+				},
+				{
+					name: 'product-13',
+					searchLabel: 'undefined 13',
+					label: '(no title)',
+					icon: box,
+				},
+			],
+		} );
+
+		const close = jest.fn();
+		result.current.commands[ 0 ].callback( { close } );
+		expect( addQueryArgs ).toHaveBeenLastCalledWith( 'post.php', {
+			post: 12,
+			action: 'edit',
+		} );
+		expect( decodeURIComponent( window.location.hash ) ).toBe(
+			addQueryArgs.mock.results.at( -1 ).value
+		);
+		expect( close ).toHaveBeenCalledTimes( 1 );
+		expect( queueRecordEvent ).toHaveBeenLastCalledWith(
+			'woocommerce_command_palette_submit',
+			{ name: 'woocommerce/product' }
+		);
+
+		jest.advanceTimersByTime( 300 );
+		expect( recordEvent ).toHaveBeenCalledWith(
+			'woocommerce_command_palette_search',
+			{ value: 'bread' }
+		);
+
+		rerender( { search: 'butter' } );
+		expect( jest.getTimerCount() ).toBe( 1 );
+		unmount();
+		expect( jest.getTimerCount() ).toBe( 0 );
+	} );
+
+	it( 'does not track a product search after unmount', () => {
+		jest.useFakeTimers();
+		const getEntityRecords = jest.fn( () => [] );
+		const hasFinishedResolution = jest.fn( () => true );
+		useSelect.mockImplementation( ( callback ) =>
+			callback( () => ( { getEntityRecords, hasFinishedResolution } ) )
+		);
+
+		const { unmount } = renderHook( () =>
+			registeredLoader.hook( { search: 'bread' } )
+		);
+
+		unmount();
+		jest.advanceTimersByTime( 300 );
+		expect( recordEvent ).not.toHaveBeenCalled();
+	} );
+} );
diff --git a/plugins/woocommerce/tests/e2e/tests/editor/command-palette.spec.ts b/plugins/woocommerce/tests/e2e/tests/editor/command-palette.spec.ts
index a61ac4ad46f..863b7c7ebc5 100644
--- a/plugins/woocommerce/tests/e2e/tests/editor/command-palette.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/editor/command-palette.spec.ts
@@ -1,7 +1,7 @@
 /**
  * External dependencies
  */
-import { Page } from '@playwright/test';
+import { Locator, Page } from '@playwright/test';
 import { WC_API_PATH } from '@woocommerce/e2e-utils-playwright';

 /**
@@ -11,18 +11,7 @@ import { ADMIN_STATE_PATH } from '../../playwright.config';
 import { expect, test as baseTest } from '../../fixtures/fixtures';
 import { getInstalledWordPressVersion } from '../../utils/wordpress';

-const clickOnCommandPaletteOption = async ( {
-	page,
-	optionName,
-}: {
-	page: Page;
-	optionName: string;
-} ) => {
-	// Using a regex here because Gutenberg changes the text of the placeholder
-	const searchBox = page.getByPlaceholder(
-		/Search (?:commands(?: and settings)?|for commands)/
-	);
-
+const openCommandPalette = async ( page: Page ) => {
 	// WordPress registers the command-palette shortcut via @wordpress/keycodes'
 	// `isAppleOS()`, which inspects `navigator.platform` only. In Playwright's
 	// Chromium on macOS these two disagree: `navigator.platform` is "MacIntel"
@@ -51,6 +40,21 @@ const clickOnCommandPaletteOption = async ( {
 	// Press `Ctrl`/`Cmd` + `K` to open the command palette.
 	await page.keyboard.press( cmdKeyCombo );

+	// Using a regex here because Gutenberg changes the text of the placeholder
+	return page.getByPlaceholder(
+		/Search (?:commands(?: and settings)?|for commands)/
+	);
+};
+
+const findCommandPaletteOption = async ( {
+	page,
+	searchBox,
+	optionName,
+}: {
+	page: Page;
+	searchBox: Locator;
+	optionName: string;
+} ) => {
 	await searchBox.fill( optionName );

 	// TODO: WP 7.0 compat - WP 7.0 appends "Action" to command palette option
@@ -59,6 +63,22 @@ const clickOnCommandPaletteOption = async ( {
 		name: new RegExp( `^${ optionName }( Action)?$` ),
 	} );
 	await expect( option ).toBeVisible();
+	return option;
+};
+
+const clickOnCommandPaletteOption = async ( {
+	page,
+	optionName,
+}: {
+	page: Page;
+	optionName: string;
+} ) => {
+	const searchBox = await openCommandPalette( page );
+	const option = await findCommandPaletteOption( {
+		page,
+		searchBox,
+		optionName,
+	} );
 	await option.click();
 };

@@ -106,50 +126,25 @@ const test = baseTest.extend( {
 } );

 test( 'can use the "Add new product" command', async ( { page } ) => {
-	await clickOnCommandPaletteOption( {
-		page,
-		optionName: 'Add new product',
-	} );
+	const searchBox = await openCommandPalette( page );

-	// Verify that the page has loaded.
-	await expect(
-		page.getByRole( 'heading', { name: 'Add new product' } )
-	).toBeVisible();
-} );
-
-test( 'can use the "Add new order" command', async ( { page } ) => {
-	await clickOnCommandPaletteOption( {
-		page,
-		optionName: 'Add new order',
-	} );
-
-	// Verify that the page has loaded.
-	await expect(
-		page.getByRole( 'heading', { name: 'Add new order' } )
-	).toBeVisible();
-} );
-
-test( 'can use the "Products" command', async ( { page } ) => {
-	await clickOnCommandPaletteOption( {
+	// Analytics commands come from a separate bundle fed by PHP, so check that one reached the palette too.
+	await findCommandPaletteOption( {
 		page,
-		optionName: 'Products',
+		searchBox,
+		optionName: 'WooCommerce Analytics: Products',
 	} );

-	// Verify that the page has loaded.
-	await expect(
-		page.locator( 'h1' ).filter( { hasText: 'Products' } ).first()
-	).toBeVisible();
-} );
-
-test( 'can use the "Orders" command', async ( { page } ) => {
-	await clickOnCommandPaletteOption( {
+	const option = await findCommandPaletteOption( {
 		page,
-		optionName: 'Orders',
+		searchBox,
+		optionName: 'Add new product',
 	} );
+	await option.click();

 	// Verify that the page has loaded.
 	await expect(
-		page.locator( 'h1' ).filter( { hasText: 'Orders' } ).first()
+		page.getByRole( 'heading', { name: 'Add new product' } )
 	).toBeVisible();
 } );

@@ -164,17 +159,3 @@ test( 'can use the product search command', async ( { page, product } ) => {
 		`${ product.name }`
 	);
 } );
-
-test( 'can use an analytics command', async ( { page } ) => {
-	await clickOnCommandPaletteOption( {
-		page,
-		optionName: 'WooCommerce Analytics: Products',
-	} );
-
-	// Verify that the page has loaded.
-	await expect(
-		page.locator( 'h1' ).filter( { hasText: 'Products' } )
-	).toBeVisible();
-	const pageTitle = await page.title();
-	expect( pageTitle.includes( 'Products ‹ Analytics' ) ).toBeTruthy();
-} );
diff --git a/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-assets-test.php b/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-assets-test.php
index 0a23cf9de64..912c6e13ee1 100644
--- a/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-assets-test.php
+++ b/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-assets-test.php
@@ -15,6 +15,13 @@ class WC_Admin_Assets_Test extends WC_Unit_Test_Case {
 	 */
 	private $sut;

+	/**
+	 * Directories and files a test created for a stub asset registry, in creation order.
+	 *
+	 * @var string[]
+	 */
+	private $created_asset_paths = array();
+
 	/**
 	 * Set up before each test.
 	 */
@@ -28,12 +35,25 @@ class WC_Admin_Assets_Test extends WC_Unit_Test_Case {
 	 * Tear down after each test.
 	 */
 	public function tearDown(): void {
-		unset( $_GET['page'] );
-		wp_dequeue_script( 'woocommerce_admin' );
-		wp_dequeue_script( 'woocommerce_quick-edit' );
-		wp_dequeue_script( 'jquery-ui-datepicker' );
-		wp_dequeue_script( 'heartbeat' );
-		parent::tearDown();
+		try {
+			unset( $_GET['page'] );
+			wp_dequeue_script( 'woocommerce_admin' );
+			wp_dequeue_script( 'woocommerce_quick-edit' );
+			wp_dequeue_script( 'jquery-ui-datepicker' );
+			wp_dequeue_script( 'heartbeat' );
+			// wp_localize_script() appends to a handle's data, and nothing resets wp_scripts() between tests.
+			wp_deregister_script( 'wc-admin-command-palette' );
+			wp_deregister_script( 'wc-admin-command-palette-analytics' );
+			foreach ( array_reverse( $this->created_asset_paths ) as $path ) {
+				if ( is_dir( $path ) ) {
+					rmdir( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir
+				} else {
+					wp_delete_file( $path );
+				}
+			}
+		} finally {
+			parent::tearDown();
+		}
 	}

 	/**
@@ -126,4 +146,78 @@ class WC_Admin_Assets_Test extends WC_Unit_Test_Case {

 		$this->assertStringContainsString( 'id="wc-lost-connection-notice"', $output );
 	}
+
+	/**
+	 * @testdox Should localize the Analytics reports from Analytics::get_report_pages() for the command palette.
+	 */
+	public function test_enqueue_command_palette_assets_localizes_analytics_reports(): void {
+		$this->ensure_wp_admin_script_asset_registry( 'command-palette' );
+		$this->ensure_wp_admin_script_asset_registry( 'command-palette-analytics' );
+		add_filter(
+			'woocommerce_analytics_report_menu_items',
+			static function () {
+				return array(
+					array(
+						'id'    => 'test-analytics-revenue',
+						'title' => 'Revenue <em>report</em>',
+						'path'  => '/analytics/revenue',
+					),
+					array(
+						'id'    => 'test-analytics-customers',
+						'title' => 'Customers',
+						'path'  => '/customers',
+					),
+				);
+			}
+		);
+
+		$this->sut->enqueue_command_palette_assets();
+
+		$this->assertTrue( wp_script_is( 'wc-admin-command-palette-analytics', 'enqueued' ), 'The Analytics command palette bundle should be enqueued' );
+		$localized = $this->get_localized_object( 'wc-admin-command-palette-analytics', 'wcCommandPaletteAnalytics' );
+		$this->assertIsArray( $localized->reports ?? null, 'Reports should reach JS as an array, the only shape the command palette registers' );
+		$this->assertSame( array( 'Revenue report', 'Customers' ), array_column( $localized->reports, 'title' ), 'Each report title should be passed with its HTML stripped' );
+		$this->assertSame( array( '/analytics/revenue', '/customers' ), array_column( $localized->reports, 'path' ), 'Each report path should be passed unchanged' );
+	}
+
+	/**
+	 * Writes a stub asset registry for a wp-admin-scripts bundle that is not built, as in the CI PHPUnit jobs.
+	 * tearDown() removes every directory and file this creates.
+	 *
+	 * @param string $script_name Bundle name under wp-admin-scripts.
+	 */
+	private function ensure_wp_admin_script_asset_registry( string $script_name ): void {
+		$dir = WC_ADMIN_ABSPATH . WC_ADMIN_DIST_JS_FOLDER . 'wp-admin-scripts';
+		if ( is_readable( "{$dir}/{$script_name}.asset.php" ) || is_readable( "{$dir}/{$script_name}.min.asset.php" ) ) {
+			return;
+		}
+
+		$missing_dirs = array();
+		$path         = $dir;
+		while ( ! is_dir( $path ) ) {
+			array_unshift( $missing_dirs, $path );
+			$path = dirname( $path );
+		}
+		$this->assertTrue( wp_mkdir_p( $dir ), "{$dir} should be writable for the stub asset registry" );
+		$this->created_asset_paths = array_merge( $this->created_asset_paths, $missing_dirs );
+
+		$file = "{$dir}/{$script_name}.asset.php";
+		file_put_contents( $file, "<?php return array( 'dependencies' => array(), 'version' => 'test' );" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
+		$this->created_asset_paths[] = $file;
+	}
+
+	/**
+	 * Decodes the JS global a script handle's localized data assigns.
+	 *
+	 * @param string $handle      Script handle.
+	 * @param string $object_name JS global name.
+	 * @return mixed The decoded value.
+	 */
+	private function get_localized_object( string $handle, string $object_name ) {
+		$data   = (string) wp_scripts()->get_data( $handle, 'data' );
+		$prefix = "var {$object_name} = ";
+		$this->assertStringStartsWith( $prefix, $data, "{$handle} should localize {$object_name}" );
+
+		return json_decode( rtrim( substr( $data, strlen( $prefix ) ), ';' ) );
+	}
 }