diff --git a/package.json b/package.json index 48fae558a7..f3610fb371 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "test-e2e": "wp-scripts test-playwright --config tools/e2e/playwright.config.ts", "test-e2e:debug": "wp-scripts test-playwright --config tools/e2e/playwright.config.ts --ui", "test-e2e:auto-sizes": "wp-scripts test-playwright --config tools/e2e/playwright.config.ts --project=auto-sizes", + "test-e2e:performance-lab": "wp-scripts test-playwright --config tools/e2e/playwright.config.ts --project=performance-lab", "lint-php": "composer lint:all", "test-php": "wp-env --config=.wp-env.test.json run wordpress --env-cwd=/var/www/html/wp-content/plugins/performance composer test:plugins", "test-php-watch": "./bin/test-php-watch.sh", diff --git a/plugins/performance-lab/includes/devtools/devtools-discovery.js b/plugins/performance-lab/includes/devtools/devtools-discovery.js new file mode 100644 index 0000000000..4b93883eba --- /dev/null +++ b/plugins/performance-lab/includes/devtools/devtools-discovery.js @@ -0,0 +1,133 @@ +/** + * Chrome DevTools third-party tools integration for Performance Lab. + * + * Registers a group of read-only tools with Chrome DevTools for agents, via its experimental + * third-party tools API, so that an AI agent can inspect WordPress server-side performance state + * (Server-Timing metrics, database queries, and environment info) while debugging a page. + * + * Tool names and descriptions are deliberately not internationalized since they are consumed by + * AI agents rather than shown to users. + * + * @since n.e.x.t + * + * @see https://developer.chrome.com/docs/devtools/agents/use-cases/third-party-tools + */ + +/** + * Event dispatched by Chrome DevTools for agents to discover third-party tools. + * + * This is a new, experimental, Chrome-only browser API not yet reflected in TypeScript's DOM lib. + * + * @typedef {Event & { respondWith: (toolGroup: Object) => void }} DevtoolsToolDiscoveryEvent + */ + +/** + * Database query captured via the SAVEQUERIES constant. + * + * @typedef {Object} DevtoolsDatabaseQuery + * @property {string} sql SQL statement. + * @property {number} timeMs Query duration in milliseconds. + * @property {string} caller Comma-separated call stack of the calling functions. + */ + +/** + * Data printed by perflab_devtools_print_data() in PHP. + * + * @typedef {Object} DevtoolsData + * @property {Object} environment Environment info. + * @property {null|{ count: number, totalTimeMs: number, queries: DevtoolsDatabaseQuery[] }} dbQueries Database queries, or null if SAVEQUERIES is not enabled. + */ + +/** + * Data captured server-side for the current page load, or null if unavailable. + * + * @type {DevtoolsData|null} + */ +let data = null; + +const dataScript = document.getElementById( 'perflab-devtools-data' ); +if ( dataScript instanceof HTMLScriptElement ) { + try { + data = JSON.parse( dataScript.text ); + } catch { + // Tolerate malformed data; the tools will report it as unavailable. + } +} + +window.addEventListener( 'devtoolstooldiscovery', ( event ) => { + const discoveryEvent = /** @type {DevtoolsToolDiscoveryEvent} */ ( event ); + discoveryEvent.respondWith( { + name: 'WordPress Performance Lab', + description: + 'Inspect WordPress server-side performance state for the current page load, including Server-Timing metrics, database queries, and environment info.', + tools: [ + { + name: 'get_server_timing_metrics', + description: + 'Returns the Server-Timing metrics which WordPress sent in the HTML response for the current page load, as name/duration (milliseconds)/description entries. Metrics are managed by the Performance Lab plugin and configured in WP Admin under Settings > Performance; plugins can register additional metrics via the Performance Lab Server-Timing PHP API.', + inputSchema: { type: 'object', properties: {} }, + execute: async () => { + const [ navigationEntry ] = + /** @type {PerformanceNavigationTiming[]} */ ( + performance.getEntriesByType( 'navigation' ) + ); + if ( ! navigationEntry ) { + return []; + } + return navigationEntry.serverTiming.map( + ( { name, duration, description } ) => ( { + name, + duration, + description, + } ) + ); + }, + }, + { + name: 'get_environment_info', + description: + 'Returns information about the WordPress environment which served the current page: WordPress and PHP versions, active theme, whether a persistent object cache is in use, whether the WP_DEBUG and SAVEQUERIES constants are enabled, whether it is a multisite, and the list of active plugins.', + inputSchema: { type: 'object', properties: {} }, + execute: async () => ( data ? data.environment : null ), + }, + { + name: 'get_database_queries', + description: + 'Returns the SQL database queries which WordPress executed while rendering the current page (up until the end of wp_footer), including each query’s SQL, duration in milliseconds, and calling function stack, along with the total query count and cumulative duration. Requires the SAVEQUERIES constant to be defined as true in wp-config.php.', + inputSchema: { + type: 'object', + properties: { + limit: { + type: 'integer', + description: + 'Optional maximum number of queries to return. When provided, the slowest queries are returned first.', + }, + }, + }, + execute: async ( + /** @type {{ limit?: number }} */ { limit } = {} + ) => { + if ( ! data || ! data.dbQueries ) { + return { + available: false, + reason: 'Database queries were not captured. Define the SAVEQUERIES constant as true in wp-config.php to capture them.', + }; + } + const { count, totalTimeMs, queries } = data.dbQueries; + let results = queries; + if ( typeof limit === 'number' && limit > 0 ) { + results = [ ...queries ] + .sort( ( a, b ) => b.timeMs - a.timeMs ) + .slice( 0, limit ); + } + return { + available: true, + count, + totalTimeMs, + queries: results, + }; + }, + }, + ], + } ); +} ); diff --git a/plugins/performance-lab/includes/devtools/helper.php b/plugins/performance-lab/includes/devtools/helper.php new file mode 100644 index 0000000000..9975f6b052 --- /dev/null +++ b/plugins/performance-lab/includes/devtools/helper.php @@ -0,0 +1,204 @@ + perflab_devtools_get_environment_info(), + 'dbQueries' => perflab_devtools_get_database_queries(), + ); + + $json_flags = JSON_HEX_TAG | JSON_UNESCAPED_SLASHES; + if ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ) { + $json_flags |= JSON_PRETTY_PRINT; + } + wp_print_inline_script_tag( + (string) wp_json_encode( $data, $json_flags ), + array( + 'type' => 'application/json', + 'id' => 'perflab-devtools-data', + ) + ); +} + +/** + * Gets information about the WordPress environment serving the current page. + * + * Only non-sensitive, high-level information is included, and the data is only ever exposed to + * users with the capability returned by {@see perflab_devtools_get_capability()}. + * + * @since n.e.x.t + * + * @return array Environment info. + */ +function perflab_devtools_get_environment_info(): array { + $theme = wp_get_theme(); + + return array( + 'wpVersion' => get_bloginfo( 'version' ), + 'phpVersion' => phpversion(), + 'theme' => array( + 'name' => $theme->get( 'Name' ), + 'stylesheet' => $theme->get_stylesheet(), + 'version' => $theme->get( 'Version' ), + ), + 'usingExternalObjectCache' => (bool) wp_using_ext_object_cache(), + 'wpDebug' => defined( 'WP_DEBUG' ) && WP_DEBUG, + 'saveQueries' => defined( 'SAVEQUERIES' ) && SAVEQUERIES, + 'isMultisite' => is_multisite(), + 'activePlugins' => array_values( (array) get_option( 'active_plugins', array() ) ), + ); +} + +/** + * Gets the database queries executed during the current request. + * + * Requires the SAVEQUERIES constant to be defined as true, since otherwise WordPress does not + * collect queries. Individual SQL strings are truncated to a reasonable length to keep the + * payload size in check. + * + * @since n.e.x.t + * + * @return array{ count: int, totalTimeMs: float, queries: array }|null Queries data, or null if SAVEQUERIES is not enabled. + */ +function perflab_devtools_get_database_queries(): ?array { + if ( ! defined( 'SAVEQUERIES' ) || ! SAVEQUERIES ) { + return null; + } + + // If no queries have been run yet, $wpdb->queries will be null, which is valid (0 queries). + $saved_queries = $GLOBALS['wpdb']->queries ?? array(); + if ( ! is_array( $saved_queries ) ) { + return null; + } + + return perflab_devtools_map_database_queries( $saved_queries ); +} + +/** + * Maps queries saved by wpdb via the SAVEQUERIES constant to the shape exposed to DevTools. + * + * @since n.e.x.t + * + * @param array $saved_queries Queries as saved in wpdb::$queries, where each entry is expected to be + * an array containing the SQL, the time taken in seconds, and the caller. + * @return array{ count: int, totalTimeMs: float, queries: array } Queries data. + */ +function perflab_devtools_map_database_queries( array $saved_queries ): array { + $max_sql_length = 2000; + $queries = array(); + $total_time_ms = 0.0; + foreach ( $saved_queries as $saved_query ) { + if ( ! is_array( $saved_query ) || ! isset( $saved_query[0], $saved_query[1], $saved_query[2] ) ) { + continue; + } + $sql = trim( (string) $saved_query[0] ); + if ( strlen( $sql ) > $max_sql_length ) { + $sql = substr( $sql, 0, $max_sql_length ) . '…'; + } + $time_ms = (float) $saved_query[1] * 1000.0; + $total_time_ms += $time_ms; + $queries[] = array( + 'sql' => $sql, + 'timeMs' => round( $time_ms, 3 ), + 'caller' => (string) $saved_query[2], + ); + } + + return array( + 'count' => count( $queries ), + 'totalTimeMs' => round( $total_time_ms, 3 ), + 'queries' => $queries, + ); +} diff --git a/plugins/performance-lab/includes/devtools/hooks.php b/plugins/performance-lab/includes/devtools/hooks.php new file mode 100644 index 0000000000..77358e90e3 --- /dev/null +++ b/plugins/performance-lab/includes/devtools/hooks.php @@ -0,0 +1,18 @@ + { + test.beforeAll( async ( { requestUtils } ) => { + await requestUtils.activateTheme( 'twentytwentyfour' ); + await requestUtils.activatePlugin( 'performance-lab' ); + } ); + + test( 'registers a tool group for administrators', async ( { page } ) => { + await page.goto( '/' ); + + // The data consumed by the tools is printed in the footer for authorized users. + await expect( page.locator( '#perflab-devtools-data' ) ).toHaveCount( + 1 + ); + + /* + * Simulate the discovery event which Chrome DevTools for agents dispatches, capturing + * the tool group passed to respondWith() and invoking each exposed tool. + */ + const result = await page.evaluate( async () => { + /** + * @typedef {{ name: string, execute: ( input: Object ) => Promise<*> }} Tool + * @typedef {{ name: string, tools: Tool[] }} ToolGroup + */ + const event = new Event( 'devtoolstooldiscovery' ); + /** @type {ToolGroup|null} */ + let toolGroup = null; + // @ts-ignore -- respondWith is expected on the event by the listener. + event.respondWith = ( /** @type {ToolGroup} */ group ) => { + toolGroup = group; + }; + window.dispatchEvent( event ); + if ( ! toolGroup ) { + return null; + } + const respondedGroup = /** @type {ToolGroup} */ ( toolGroup ); + const getTool = ( /** @type {string} */ name ) => { + const tool = respondedGroup.tools.find( + ( candidate ) => candidate.name === name + ); + if ( ! tool ) { + throw new Error( `Tool not found: ${ name }` ); + } + return tool; + }; + return { + name: respondedGroup.name, + toolNames: respondedGroup.tools.map( ( tool ) => tool.name ), + environment: await getTool( 'get_environment_info' ).execute( + {} + ), + serverTiming: await getTool( + 'get_server_timing_metrics' + ).execute( {} ), + dbQueries: await getTool( 'get_database_queries' ).execute( + {} + ), + }; + } ); + + if ( ! result ) { + throw new Error( + 'The devtoolstooldiscovery listener did not respond with a tool group.' + ); + } + expect( result.name ).toBe( 'WordPress Performance Lab' ); + expect( result.toolNames ).toEqual( [ + 'get_server_timing_metrics', + 'get_environment_info', + 'get_database_queries', + ] ); + expect( result.environment.wpVersion ).toEqual( expect.any( String ) ); + expect( result.environment.phpVersion ).toEqual( expect.any( String ) ); + expect( Array.isArray( result.environment.activePlugins ) ).toBe( + true + ); + expect( Array.isArray( result.serverTiming ) ).toBe( true ); + expect( result.dbQueries ).toHaveProperty( 'available' ); + } ); + + test( 'does not expose data or tools to logged-out visitors', async ( { + browser, + } ) => { + /* + * An empty storage state is passed since browser.newContext() otherwise inherits the + * authenticated admin storage state from the Playwright config. + */ + const context = await browser.newContext( { + storageState: { cookies: [], origins: [] }, + } ); + const loggedOutPage = await context.newPage(); + /* + * A query string distinct from the URL loaded in the previous test is used so that the + * browser's shared HTTP cache cannot serve the HTML rendered for the authenticated user. + */ + await loggedOutPage.goto( '/?logged-out' ); + + await expect( + loggedOutPage.locator( '#perflab-devtools-data' ) + ).toHaveCount( 0 ); + + const responded = await loggedOutPage.evaluate( () => { + const event = new Event( 'devtoolstooldiscovery' ); + let didRespond = false; + // @ts-ignore -- respondWith is expected on the event by the listener. + event.respondWith = () => { + didRespond = true; + }; + window.dispatchEvent( event ); + return didRespond; + } ); + expect( responded ).toBe( false ); + + await context.close(); + } ); +} ); diff --git a/plugins/performance-lab/tests/includes/devtools/test-helper.php b/plugins/performance-lab/tests/includes/devtools/test-helper.php new file mode 100644 index 0000000000..d7338e9faa --- /dev/null +++ b/plugins/performance-lab/tests/includes/devtools/test-helper.php @@ -0,0 +1,215 @@ +user->create( array( 'role' => 'administrator' ) ); + self::$subscriber_id = $factory->user->create( array( 'role' => 'subscriber' ) ); + } + + public function tear_down(): void { + wp_dequeue_script_module( 'perflab-devtools-discovery' ); + wp_deregister_script_module( 'perflab-devtools-discovery' ); + parent::tear_down(); + } + + public function test_hooks_added(): void { + $this->assertSame( 10, has_action( 'wp_enqueue_scripts', 'perflab_devtools_enqueue_script_module' ) ); + $this->assertSame( PHP_INT_MAX, has_action( 'wp_footer', 'perflab_devtools_print_data' ) ); + } + + /** + * @covers ::perflab_devtools_get_capability + */ + public function test_perflab_devtools_get_capability_default(): void { + $this->assertSame( 'manage_options', perflab_devtools_get_capability() ); + } + + /** + * @covers ::perflab_devtools_get_capability + */ + public function test_perflab_devtools_get_capability_filtered(): void { + add_filter( + 'perflab_devtools_capability', + static function (): string { + return 'edit_posts'; + } + ); + $this->assertSame( 'edit_posts', perflab_devtools_get_capability() ); + } + + /** + * @covers ::perflab_devtools_get_capability + */ + public function test_perflab_devtools_get_capability_invalid_filter_value_falls_back_to_default(): void { + add_filter( 'perflab_devtools_capability', '__return_empty_string' ); + $this->assertSame( 'manage_options', perflab_devtools_get_capability() ); + + remove_filter( 'perflab_devtools_capability', '__return_empty_string' ); + add_filter( 'perflab_devtools_capability', '__return_false' ); + $this->assertSame( 'manage_options', perflab_devtools_get_capability() ); + } + + /** + * @covers ::perflab_devtools_get_asset_path + */ + public function test_perflab_devtools_get_asset_path(): void { + $path = perflab_devtools_get_asset_path(); + $this->assertStringStartsWith( 'includes/devtools/devtools-discovery', $path ); + $this->assertStringEndsWith( '.js', $path ); + if ( 'includes/devtools/devtools-discovery.min.js' === $path ) { + $this->assertFileExists( PERFLAB_PLUGIN_DIR_PATH . $path ); + } + } + + /** + * @covers ::perflab_devtools_enqueue_script_module + */ + public function test_perflab_devtools_enqueue_script_module_for_unauthorized_user(): void { + wp_set_current_user( self::$subscriber_id ); + perflab_devtools_enqueue_script_module(); + $this->assertStringNotContainsString( 'devtools-discovery', get_echo( array( wp_script_modules(), 'print_enqueued_script_modules' ) ) ); + } + + /** + * @covers ::perflab_devtools_enqueue_script_module + */ + public function test_perflab_devtools_enqueue_script_module_for_admin(): void { + wp_set_current_user( self::$admin_id ); + perflab_devtools_enqueue_script_module(); + $output = get_echo( array( wp_script_modules(), 'print_enqueued_script_modules' ) ); + $this->assertStringContainsString( 'devtools-discovery', $output ); + $this->assertStringContainsString( 'type="module"', $output ); + } + + /** + * @covers ::perflab_devtools_print_data + */ + public function test_perflab_devtools_print_data_for_unauthorized_user(): void { + wp_set_current_user( self::$subscriber_id ); + $this->assertSame( '', get_echo( 'perflab_devtools_print_data' ) ); + + wp_set_current_user( 0 ); + $this->assertSame( '', get_echo( 'perflab_devtools_print_data' ) ); + } + + /** + * @covers ::perflab_devtools_print_data + */ + public function test_perflab_devtools_print_data_for_admin(): void { + wp_set_current_user( self::$admin_id ); + $output = get_echo( 'perflab_devtools_print_data' ); + $this->assertStringContainsString( 'id="perflab-devtools-data"', $output ); + $this->assertStringContainsString( 'type="application/json"', $output ); + + $data = $this->get_printed_data( $output ); + $this->assertIsArray( $data ); + $this->assertArrayHasKey( 'environment', $data ); + $this->assertArrayHasKey( 'dbQueries', $data ); + } + + /** + * @covers ::perflab_devtools_print_data + */ + public function test_perflab_devtools_print_data_with_filtered_capability(): void { + wp_set_current_user( self::$subscriber_id ); + add_filter( + 'perflab_devtools_capability', + static function (): string { + return 'read'; + } + ); + $output = get_echo( 'perflab_devtools_print_data' ); + $this->assertStringContainsString( 'id="perflab-devtools-data"', $output ); + } + + /** + * @covers ::perflab_devtools_get_environment_info + */ + public function test_perflab_devtools_get_environment_info(): void { + $info = perflab_devtools_get_environment_info(); + $this->assertSame( get_bloginfo( 'version' ), $info['wpVersion'] ); + $this->assertSame( phpversion(), $info['phpVersion'] ); + $this->assertSame( get_stylesheet(), $info['theme']['stylesheet'] ); + $this->assertIsBool( $info['usingExternalObjectCache'] ); + $this->assertIsBool( $info['wpDebug'] ); + $this->assertIsBool( $info['saveQueries'] ); + $this->assertSame( is_multisite(), $info['isMultisite'] ); + $this->assertIsArray( $info['activePlugins'] ); + } + + /** + * @covers ::perflab_devtools_get_database_queries + */ + public function test_perflab_devtools_get_database_queries(): void { + if ( defined( 'SAVEQUERIES' ) && SAVEQUERIES ) { + $queries = perflab_devtools_get_database_queries(); + $this->assertIsArray( $queries ); + $this->assertArrayHasKey( 'count', $queries ); + $this->assertArrayHasKey( 'totalTimeMs', $queries ); + $this->assertArrayHasKey( 'queries', $queries ); + } else { + $this->assertNull( perflab_devtools_get_database_queries() ); + } + } + + /** + * @covers ::perflab_devtools_map_database_queries + */ + public function test_perflab_devtools_map_database_queries(): void { + $long_sql = 'SELECT ' . str_repeat( 'option_name, ', 500 ) . 'option_value FROM wp_options'; + + $mapped = perflab_devtools_map_database_queries( + array( + array( ' SELECT * FROM wp_posts ', 0.0123, 'require, wp, WP_Query->get_posts' ), + array( $long_sql, 0.5, 'require, get_option' ), + 'not-an-array', + array( 'missing time and caller' ), + ) + ); + + $this->assertSame( 2, $mapped['count'] ); + $this->assertSame( 512.3, $mapped['totalTimeMs'] ); + $this->assertCount( 2, $mapped['queries'] ); + + $this->assertSame( 'SELECT * FROM wp_posts', $mapped['queries'][0]['sql'] ); + $this->assertSame( 12.3, $mapped['queries'][0]['timeMs'] ); + $this->assertSame( 'require, wp, WP_Query->get_posts', $mapped['queries'][0]['caller'] ); + + $this->assertSame( 2000 + strlen( '…' ), strlen( $mapped['queries'][1]['sql'] ) ); + $this->assertStringEndsWith( '…', $mapped['queries'][1]['sql'] ); + } + + /** + * Extracts and decodes the JSON payload from the printed script tag. + * + * @param string $output Output of perflab_devtools_print_data(). + * @return mixed Decoded data. + */ + private function get_printed_data( string $output ) { + $this->assertSame( 1, preg_match( '#]*>(.+)#s', $output, $matches ) ); + return json_decode( $matches[1], true ); + } +} diff --git a/tools/e2e/playwright.config.ts b/tools/e2e/playwright.config.ts index 4da48adedc..893f7a59b2 100644 --- a/tools/e2e/playwright.config.ts +++ b/tools/e2e/playwright.config.ts @@ -16,6 +16,10 @@ const config = defineConfig( { name: 'auto-sizes', testDir: '../../plugins/auto-sizes/tests/e2e/specs', }, + { + name: 'performance-lab', + testDir: '../../plugins/performance-lab/tests/e2e/specs', + }, ], } ); diff --git a/webpack.config.js b/webpack.config.js index a21df0cedd..bca1de3881 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -68,6 +68,10 @@ const performanceLab = ( env ) => { from: `${ pluginDir }/includes/admin/plugin-activate-ajax.js`, to: `${ pluginDir }/includes/admin/plugin-activate-ajax.min.js`, }, + { + from: `${ pluginDir }/includes/devtools/devtools-discovery.js`, + to: `${ pluginDir }/includes/devtools/devtools-discovery.min.js`, + }, ], } ), // @ts-expect-error TS2351: WebpackBar is constructable when using require(), type definitions might be geared towards ESM.