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( '&', '&' ),
+} ) );
+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 & 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 & 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 ) ), ';' ) );
+ }
}