diff --git a/.wp-env.test.json b/.wp-env.test.json index fb5b624ff..0ed145aa7 100644 --- a/.wp-env.test.json +++ b/.wp-env.test.json @@ -5,7 +5,7 @@ "core": null, "plugins": [ ".", - "./tests/e2e-request-mocking", + "./tests/e2e-testing", "https://downloads.wordpress.org/plugin/ai-provider-for-google.zip", "https://downloads.wordpress.org/plugin/ai-provider-for-openai.zip" ], diff --git a/docs/ARCHITECTURE_OVERVIEW.md b/docs/ARCHITECTURE_OVERVIEW.md index baa2c5941..2077aaa38 100644 --- a/docs/ARCHITECTURE_OVERVIEW.md +++ b/docs/ARCHITECTURE_OVERVIEW.md @@ -35,6 +35,6 @@ ai/ └── tests/ # Tests ├── Integration/ # Integration tests for WordPress + Plugin ├── e2e/ # Playwright end-to-end tests - ├── e2e-request-mocking/ # Mock API calls for e2e tests + ├── e2e-testing/ # Support plugin for e2e tests (API mocking, fixtures) └── bootstrap.php # PHPUnit bootstrap ``` diff --git a/includes/Abilities/Settings/Settings.php b/includes/Abilities/Settings/Settings.php new file mode 100644 index 000000000..a6be43b44 --- /dev/null +++ b/includes/Abilities/Settings/Settings.php @@ -0,0 +1,315 @@ +}>|null + */ + private $exposed_settings = null; + + /** + * Hooks the ability into the Abilities API. + * + * Plugin: this method has no equivalent in the core class. In core, register() is + * invoked directly from wp_register_core_abilities() (already on the + * `wp_abilities_api_init` hook). The plugin instead hooks register() slightly later + * (priority 11) so it can override any core-provided copy. + * + * @since x.x.x + */ + public function init(): void { + add_action( 'wp_abilities_api_init', array( $this, 'register' ), 11 ); + } + + /** + * Registers all settings abilities. + * + * Must run on the `wp_abilities_api_init` hook. + * + * @since x.x.x + */ + public function register(): void { + $this->register_get_settings(); + + /* + * A future write-oriented ability can be registered here, reusing the shared + * helpers below (get_exposed_settings(), value_schema(), cast_value()): + * + * $this->register_manage_settings(); + */ + } + + /** + * Registers the read-only `core/settings` ability. + * + * @since x.x.x + */ + private function register_get_settings(): void { + // Plugin: unregister any core-provided copy first so the plugin's version wins. + if ( wp_has_ability( 'core/settings' ) ) { + wp_unregister_ability( 'core/settings' ); + } + + // Compute once; execute_get_settings() reuses this exact structure. + $this->exposed_settings = $this->get_exposed_settings(); + + $settings = $this->exposed_settings; + $field_names = array_keys( $settings ); + $groups = array(); + $properties = array(); + foreach ( $settings as $exposed_name => $setting ) { + $properties[ $exposed_name ] = $setting['schema']; + if ( '' === $setting['group'] || in_array( $setting['group'], $groups, true ) ) { + continue; + } + $groups[] = $setting['group']; + } + + wp_register_ability( + 'core/settings', + array( + 'label' => __( 'Get Settings', 'ai' ), + 'description' => __( 'Returns WordPress settings as a flat map of setting name to value. By default returns all settings exposed to abilities, or optionally a subset filtered by settings group, by setting name, or both.', 'ai' ), + 'category' => self::CATEGORY, + 'input_schema' => $this->get_settings_input_schema( $groups, $field_names ), + 'output_schema' => array( + 'type' => 'object', + 'description' => __( 'A map of setting name to its current value.', 'ai' ), + 'properties' => $properties, + 'additionalProperties' => false, + ), + 'execute_callback' => array( $this, 'execute_get_settings' ), + 'permission_callback' => array( $this, 'has_permission' ), + 'meta' => array( + 'annotations' => array( + 'readonly' => true, + 'destructive' => false, + 'idempotent' => true, + ), + 'show_in_rest' => true, + ), + ) + ); + } + + /** + * Executes the `core/settings` ability. + * + * @since x.x.x + * + * @param mixed $input Optional. The ability input. Default empty array. + * @return array Map of exposed setting name to current value. + */ + public function execute_get_settings( $input = array() ): array { + $input = is_array( $input ) ? $input : array(); + + $settings = $this->exposed_settings; + if ( null === $settings ) { + // The cache is populated in register_get_settings() before the ability is + // registered, so this is unreachable in practice; bail defensively otherwise. + return array(); + } + + $group = isset( $input['group'] ) && is_string( $input['group'] ) ? $input['group'] : ''; + $fields = isset( $input['fields'] ) && is_array( $input['fields'] ) ? $input['fields'] : array(); + + $result = array(); + foreach ( $settings as $exposed_name => $setting ) { + if ( '' !== $group && $setting['group'] !== $group ) { + continue; + } + if ( ! empty( $fields ) && ! in_array( $exposed_name, $fields, true ) ) { + continue; + } + + $type = isset( $setting['schema']['type'] ) && is_string( $setting['schema']['type'] ) ? $setting['schema']['type'] : 'string'; + $value = get_option( $setting['option'], $setting['default'] ); + + $result[ $exposed_name ] = $this->cast_value( $value, $type ); + } + + return $result; + } + + /** + * Checks whether the current user may use the settings abilities. + * + * @since x.x.x + * + * @return bool True if the current user can manage options. + */ + public function has_permission(): bool { + return current_user_can( 'manage_options' ); + } + + /** + * Builds the input schema for the get ability: optional filters by group and/or name. + * + * Both `group` and `fields` are optional; supplying both narrows the response to their + * intersection, and supplying neither returns every exposed setting. + * + * @since x.x.x + * + * @param list $groups Available settings groups. + * @param list $field_names Available exposed setting names. + * @return array The input JSON Schema. + */ + private function get_settings_input_schema( array $groups, array $field_names ): array { + return array( + 'type' => 'object', + // Object (not array()) so the serialized schema default is {}, consistent with type:object. + 'default' => (object) array(), + 'properties' => array( + 'group' => array( + 'type' => 'string', + 'enum' => $groups, + 'description' => __( 'Return only settings that belong to this settings group.', 'ai' ), + ), + 'fields' => array( + 'type' => 'array', + 'items' => array( + 'type' => 'string', + 'enum' => $field_names, + ), + 'description' => __( 'Return only the settings with these names.', 'ai' ), + ), + ), + 'additionalProperties' => false, + ); + } + + /** + * Returns the settings exposed through the Abilities API. + * + * Reads {@see get_registered_settings()} and keeps only settings flagged with a truthy + * `show_in_abilities` argument. Each entry is keyed by its exposed name and carries the + * underlying option name, the settings group, the registration default, and a JSON Schema + * describing the value. + * + * @since x.x.x + * + * @return array}> Settings keyed by exposed name. + */ + private function get_exposed_settings(): array { + $settings = array(); + + foreach ( get_registered_settings() as $option_name => $args ) { + $show = $args['show_in_abilities'] ?? false; + if ( empty( $show ) ) { + continue; + } + + $option_name = (string) $option_name; + $exposed_name = is_array( $show ) && isset( $show['name'] ) && is_string( $show['name'] ) && '' !== $show['name'] ? $show['name'] : $option_name; + + $settings[ $exposed_name ] = array( + 'option' => $option_name, + 'group' => isset( $args['group'] ) && is_string( $args['group'] ) ? $args['group'] : '', + 'default' => array_key_exists( 'default', $args ) ? $args['default'] : false, + 'schema' => $this->value_schema( $args, $show ), + ); + } + + return $settings; + } + + /** + * Builds the JSON Schema describing a single setting's value. + * + * @since x.x.x + * + * @param array $args The setting registration arguments. + * @param bool|array $show The setting's `show_in_abilities` value. + * @return array The value JSON Schema. + */ + private function value_schema( array $args, $show ): array { + $schema = array( + 'type' => isset( $args['type'] ) && is_string( $args['type'] ) ? $args['type'] : 'string', + ); + if ( ! empty( $args['label'] ) ) { + $schema['title'] = $args['label']; + } + if ( ! empty( $args['description'] ) ) { + $schema['description'] = $args['description']; + } + if ( is_array( $show ) && isset( $show['schema'] ) && is_array( $show['schema'] ) ) { + /** @var array $show_schema */ + $show_schema = $show['schema']; + $schema = array_merge( $schema, $show_schema ); + } + + return $schema; + } + + /** + * Casts a stored option value to the type declared in its settings registration. + * + * @since x.x.x + * + * @param mixed $value The raw option value. + * @param string $type The registered setting type. + * @return mixed The value cast to the declared type. + */ + private function cast_value( $value, string $type ) { + switch ( $type ) { + case 'boolean': + return (bool) $value; + case 'integer': + return is_scalar( $value ) ? (int) $value : 0; + case 'number': + return is_scalar( $value ) ? (float) $value : 0.0; + case 'array': + return is_array( $value ) ? $value : array(); + case 'object': + // Cast to object so an empty/non-array value serializes as {} (not []) and + // satisfies the `object` output schema validated by execute(). + return (object) ( is_array( $value ) ? $value : array() ); + default: + return is_scalar( $value ) ? (string) $value : $value; + } + } +} diff --git a/includes/Abilities/Show_In_Abilities.php b/includes/Abilities/Show_In_Abilities.php new file mode 100644 index 000000000..00d2cc7c3 --- /dev/null +++ b/includes/Abilities/Show_In_Abilities.php @@ -0,0 +1,112 @@ + $args The setting registration arguments. + * @param array $defaults The default registration arguments. + * @param string $option_group The settings group. + * @param string $option_name The option name. + * @return array The (possibly amended) registration arguments. + */ + public function mark_setting( array $args, array $defaults, string $option_group, string $option_name ): array { + $settings = $this->settings_map(); + + if ( isset( $settings[ $option_name ] ) && empty( $args['show_in_abilities'] ) ) { + $args['show_in_abilities'] = $settings[ $option_name ]; + } + + return $args; + } + + /** + * Returns the curated core settings to expose, keyed by option name. + * + * The value is whatever `show_in_abilities` should contain: `true`, or an array with + * optional `name` and `schema` keys (mirroring the `show_in_rest` shape). + * + * This list is kept 1:1 with the settings core flags `show_in_abilities` on in + * `register_initial_settings()` (wp-includes/option.php), preserving the same group order. + * Keep the two in sync when adding or removing entries. + * + * @since x.x.x + * + * @return array> Settings map keyed by option name. + */ + private function settings_map(): array { + return array( + // General. + 'blogname' => true, + 'blogdescription' => true, + 'siteurl' => true, + 'admin_email' => array( 'schema' => array( 'format' => 'email' ) ), + 'timezone_string' => true, + 'date_format' => true, + 'time_format' => true, + 'start_of_week' => true, + 'WPLANG' => true, + // Writing. + 'use_smilies' => true, + 'default_category' => true, + 'default_post_format' => true, + // Reading. + 'posts_per_page' => true, + 'show_on_front' => true, + 'page_on_front' => true, + 'page_for_posts' => true, + // Discussion. + 'default_ping_status' => array( 'schema' => array( 'enum' => array( 'open', 'closed' ) ) ), + 'default_comment_status' => array( 'schema' => array( 'enum' => array( 'open', 'closed' ) ) ), + ); + } +} diff --git a/includes/Main.php b/includes/Main.php index 23bbf5184..ea74cfa63 100644 --- a/includes/Main.php +++ b/includes/Main.php @@ -11,6 +11,8 @@ namespace WordPress\AI; +use WordPress\AI\Abilities\Settings\Settings as Settings_Ability; +use WordPress\AI\Abilities\Show_In_Abilities; use WordPress\AI\Abilities\Utilities\Posts; use WordPress\AI\Admin\Activation; use WordPress\AI\Admin\Dashboard\Dashboard_Widgets; @@ -129,6 +131,11 @@ public function initialize_features(): void { // Register our post-related WordPress Abilities. ( new Posts() )->register(); + + // Expose curated core objects to the Abilities API, then register the + // `core/settings` ability (overriding any core-provided copy). + ( new Show_In_Abilities() )->register(); + ( new Settings_Ability() )->init(); } catch ( \Throwable $e ) { _doing_it_wrong( __METHOD__, diff --git a/tests/Integration/Includes/Abilities/Settings/SettingsTest.php b/tests/Integration/Includes/Abilities/Settings/SettingsTest.php new file mode 100644 index 000000000..1ced0753a --- /dev/null +++ b/tests/Integration/Includes/Abilities/Settings/SettingsTest.php @@ -0,0 +1,311 @@ +show_in_abilities = new Show_In_Abilities(); + $this->show_in_abilities->register(); + register_initial_settings(); + + // A non-core setting flagged for the Abilities API, to verify that any registered + // setting (not just the core ones) is exposed by the ability. + register_setting( + 'general', + 'core_settings_ability_test_option', + array( + 'type' => 'integer', + 'label' => 'Custom Ability Setting', + 'description' => 'A custom setting exposed through the Abilities API.', + 'show_in_abilities' => true, + 'default' => 42, + ) + ); + + $this->ensure_site_category(); + } + + /** + * Tear down test case. + * + * @since x.x.x + */ + public function tearDown(): void { + if ( wp_has_ability( 'core/settings' ) ) { + wp_unregister_ability( 'core/settings' ); + } + + remove_filter( 'register_setting_args', array( $this->show_in_abilities, 'mark_setting' ), 10 ); + unregister_setting( 'general', 'core_settings_ability_test_option' ); + wp_set_current_user( 0 ); + + parent::tearDown(); + } + + /** + * Ensures the `site` ability category exists for the ability to attach to. + * + * @since x.x.x + */ + private function ensure_site_category(): void { + if ( wp_has_ability_category( 'site' ) ) { + return; + } + + global $wp_current_filter; + $wp_current_filter[] = 'wp_abilities_api_categories_init'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Faking the action context to register within it. + try { + wp_register_ability_category( + 'site', + array( + 'label' => 'Site', + 'description' => 'Site.', + ) + ); + } finally { + array_pop( $wp_current_filter ); + } + } + + /** + * Registers the plugin's core/settings ability inside a faked init action. + * + * @since x.x.x + */ + private function register_ability(): void { + global $wp_current_filter; + $wp_current_filter[] = 'wp_abilities_api_init'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Faking the action context to register within it. + try { + ( new Settings() )->register(); + } finally { + array_pop( $wp_current_filter ); + } + } + + /** + * Logs in as an administrator so the ability's permission check passes. + * + * @since x.x.x + */ + private function become_admin(): void { + wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) ); + } + + /** + * The ability is registered in the `site` category and flagged read-only. + * + * @since x.x.x + */ + public function test_core_settings_ability_is_registered(): void { + $this->register_ability(); + + $ability = wp_get_ability( 'core/settings' ); + + $this->assertInstanceOf( WP_Ability::class, $ability ); + $this->assertSame( 'site', $ability->get_category() ); + $this->assertTrue( $ability->get_meta_item( 'show_in_rest', false ) ); + + $annotations = $ability->get_meta_item( 'annotations', array() ); + $this->assertTrue( $annotations['readonly'] ); + $this->assertFalse( $annotations['destructive'] ); + } + + /** + * When core already provides core/settings, the plugin's version replaces it. + * + * @since x.x.x + */ + public function test_override_replaces_existing_core_settings(): void { + // Simulate a core-provided ability with a different (minimal) shape. + global $wp_current_filter; + $wp_current_filter[] = 'wp_abilities_api_init'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Faking the action context to register within it. + try { + wp_register_ability( + 'core/settings', + array( + 'label' => 'Core Provided', + 'description' => 'Core provided settings ability.', + 'category' => 'site', + 'execute_callback' => static function (): array { + return array(); + }, + 'permission_callback' => '__return_true', + ) + ); + } finally { + array_pop( $wp_current_filter ); + } + + $this->assertSame( 'Core Provided', wp_get_ability( 'core/settings' )->get_label() ); + + $this->register_ability(); + + $ability = wp_get_ability( 'core/settings' ); + $this->assertSame( 'Get Settings', $ability->get_label() ); + // The plugin's shape exposes optional `group` and `fields` filters. + $this->assertArrayHasKey( 'fields', $ability->get_input_schema()['properties'] ); + } + + /** + * The input schema exposes optional `group` and `fields` filters. + * + * @since x.x.x + */ + public function test_core_settings_input_schema_exposes_group_and_fields_filters(): void { + $this->register_ability(); + + $schema = wp_get_ability( 'core/settings' )->get_input_schema(); + + $this->assertSame( 'object', $schema['type'] ); + $this->assertArrayHasKey( 'default', $schema ); + $this->assertArrayNotHasKey( 'oneOf', $schema ); + + $this->assertContains( 'general', $schema['properties']['group']['enum'] ); + $this->assertContains( 'reading', $schema['properties']['group']['enum'] ); + + $this->assertContains( 'blogname', $schema['properties']['fields']['items']['enum'] ); + $this->assertContains( 'posts_per_page', $schema['properties']['fields']['items']['enum'] ); + } + + /** + * Without input the ability returns a flat map of correctly typed setting values. + * + * @since x.x.x + */ + public function test_core_settings_returns_flat_typed_values(): void { + $this->become_admin(); + $this->register_ability(); + + update_option( 'blogname', 'My Test Site' ); + update_option( 'posts_per_page', 7 ); + update_option( 'use_smilies', '1' ); + + $result = wp_get_ability( 'core/settings' )->execute( array() ); + + $this->assertIsArray( $result ); + $this->assertSame( 'My Test Site', $result['blogname'] ); + $this->assertSame( 7, $result['posts_per_page'] ); + $this->assertTrue( $result['use_smilies'] ); + } + + /** + * The `group` filter narrows the response to a single settings group. + * + * @since x.x.x + */ + public function test_core_settings_filters_by_group(): void { + $this->become_admin(); + $this->register_ability(); + + $result = wp_get_ability( 'core/settings' )->execute( array( 'group' => 'reading' ) ); + + $this->assertArrayHasKey( 'posts_per_page', $result ); + $this->assertArrayNotHasKey( 'blogname', $result ); + } + + /** + * The `fields` filter narrows the response to the requested setting names. + * + * @since x.x.x + */ + public function test_core_settings_filters_by_fields(): void { + $this->become_admin(); + $this->register_ability(); + + $result = wp_get_ability( 'core/settings' )->execute( array( 'fields' => array( 'blogname', 'posts_per_page' ) ) ); + + $this->assertEqualSets( array( 'blogname', 'posts_per_page' ), array_keys( $result ) ); + } + + /** + * Supplying both `group` and `fields` narrows the response to their intersection. + * + * @since x.x.x + */ + public function test_core_settings_combines_group_and_fields_filters(): void { + $this->become_admin(); + $this->register_ability(); + + // `blogname` is in the `general` group and `posts_per_page` in `reading`; only the + // latter satisfies both filters. + $result = wp_get_ability( 'core/settings' )->execute( + array( + 'group' => 'reading', + 'fields' => array( 'blogname', 'posts_per_page' ), + ) + ); + + $this->assertEqualSets( array( 'posts_per_page' ), array_keys( $result ) ); + } + + /** + * Users without `manage_options` cannot run the ability. + * + * @since x.x.x + */ + public function test_core_settings_requires_manage_options(): void { + wp_set_current_user( self::factory()->user->create( array( 'role' => 'subscriber' ) ) ); + $this->register_ability(); + + $result = wp_get_ability( 'core/settings' )->execute( array() ); + + $this->assertWPError( $result ); + $this->assertSame( 'ability_invalid_permissions', $result->get_error_code() ); + } + + /** + * A setting registered with `show_in_abilities` (for example by a plugin) is exposed by the ability. + * + * @since x.x.x + */ + public function test_core_settings_exposes_a_custom_registered_setting(): void { + $this->register_ability(); + + $ability = wp_get_ability( 'core/settings' ); + + // Present in both the input `fields` enum and the output schema built at registration. + $this->assertContains( 'core_settings_ability_test_option', $ability->get_input_schema()['properties']['fields']['items']['enum'] ); + $this->assertArrayHasKey( 'core_settings_ability_test_option', $ability->get_output_schema()['properties'] ); + + // And returned, correctly typed, by execute. + $this->become_admin(); + update_option( 'core_settings_ability_test_option', 7 ); + + $result = $ability->execute( array( 'fields' => array( 'core_settings_ability_test_option' ) ) ); + + $this->assertSame( array( 'core_settings_ability_test_option' => 7 ), $result ); + } +} diff --git a/tests/Integration/Includes/Abilities/Show_In_AbilitiesTest.php b/tests/Integration/Includes/Abilities/Show_In_AbilitiesTest.php new file mode 100644 index 000000000..7c35d49ce --- /dev/null +++ b/tests/Integration/Includes/Abilities/Show_In_AbilitiesTest.php @@ -0,0 +1,141 @@ + + */ + private $registered_options = array(); + + /** + * The component under test. Held so the same instance can detach its filter on tear down. + * + * @since x.x.x + * + * @var \WordPress\AI\Abilities\Show_In_Abilities + */ + private $show_in_abilities; + + /** + * Set up test case. + * + * @since x.x.x + */ + public function setUp(): void { + parent::setUp(); + + $this->show_in_abilities = new Show_In_Abilities(); + $this->show_in_abilities->register(); + } + + /** + * Tear down test case. + * + * @since x.x.x + */ + public function tearDown(): void { + remove_filter( 'register_setting_args', array( $this->show_in_abilities, 'mark_setting' ), 10 ); + + foreach ( $this->registered_options as $option ) { + unregister_setting( 'group', $option ); + } + $this->registered_options = array(); + + parent::tearDown(); + } + + /** + * Registers a setting and tracks it for cleanup. + * + * @since x.x.x + * + * @param string $group The settings group. + * @param string $option The option name. + * @param array $args The registration arguments. + */ + private function register_setting( string $group, string $option, array $args ): void { + $this->registered_options[] = $option; + register_setting( $group, $option, $args ); + } + + /** + * A curated setting is flagged with `show_in_abilities => true`. + * + * @since x.x.x + */ + public function test_marks_curated_boolean_setting(): void { + $this->register_setting( 'general', 'blogname', array( 'type' => 'string' ) ); + + $settings = get_registered_settings(); + + $this->assertTrue( $settings['blogname']['show_in_abilities'] ); + } + + /** + * A curated setting that maps to an array value receives that array verbatim. + * + * @since x.x.x + */ + public function test_marks_curated_array_setting(): void { + $this->register_setting( 'discussion', 'default_comment_status', array( 'type' => 'string' ) ); + + $settings = get_registered_settings(); + + $this->assertSame( + array( 'schema' => array( 'enum' => array( 'open', 'closed' ) ) ), + $settings['default_comment_status']['show_in_abilities'] + ); + } + + /** + * A setting that is not in the curated map is left untouched. + * + * @since x.x.x + */ + public function test_does_not_mark_uncurated_setting(): void { + $this->register_setting( 'general', 'wpai_not_curated_option', array( 'type' => 'string' ) ); + + $settings = get_registered_settings(); + + $this->assertTrue( empty( $settings['wpai_not_curated_option']['show_in_abilities'] ) ); + } + + /** + * An explicit `show_in_abilities` value already on the setting is preserved. + * + * @since x.x.x + */ + public function test_respects_existing_value(): void { + $this->register_setting( + 'general', + 'blogname', + array( + 'type' => 'string', + 'show_in_abilities' => array( 'name' => 'custom_title' ), + ) + ); + + $settings = get_registered_settings(); + + $this->assertSame( array( 'name' => 'custom_title' ), $settings['blogname']['show_in_abilities'] ); + } +} diff --git a/tests/e2e-request-mocking/e2e-request-mocking.php b/tests/e2e-testing/e2e-testing.php similarity index 87% rename from tests/e2e-request-mocking/e2e-request-mocking.php rename to tests/e2e-testing/e2e-testing.php index bced7cbac..a87724119 100644 --- a/tests/e2e-request-mocking/e2e-request-mocking.php +++ b/tests/e2e-testing/e2e-testing.php @@ -1,7 +1,7 @@ true ) ); } +/** + * Registers a sample setting exposed to the Abilities API. + * + * Used by the core/settings E2E spec to verify the ability exposes settings registered + * by other active plugins. + */ +function ai_e2e_register_sample_setting() { + register_setting( + 'general', + 'ai_e2e_sample_setting', + array( + 'type' => 'string', + 'label' => 'AI E2E Sample Setting', + 'description' => 'A sample setting exposed to the Abilities API for end-to-end testing.', + 'show_in_abilities' => true, + 'default' => 'sample-default', + ) + ); +} + /** * Mock the HTTP requests and provide known responses. * diff --git a/tests/e2e-request-mocking/responses/Google/gemini-image.json b/tests/e2e-testing/responses/Google/gemini-image.json similarity index 100% rename from tests/e2e-request-mocking/responses/Google/gemini-image.json rename to tests/e2e-testing/responses/Google/gemini-image.json diff --git a/tests/e2e-request-mocking/responses/Google/imagen.json b/tests/e2e-testing/responses/Google/imagen.json similarity index 100% rename from tests/e2e-request-mocking/responses/Google/imagen.json rename to tests/e2e-testing/responses/Google/imagen.json diff --git a/tests/e2e-request-mocking/responses/Google/models.json b/tests/e2e-testing/responses/Google/models.json similarity index 100% rename from tests/e2e-request-mocking/responses/Google/models.json rename to tests/e2e-testing/responses/Google/models.json diff --git a/tests/e2e-request-mocking/responses/OpenAI/comment-moderation-responses.json b/tests/e2e-testing/responses/OpenAI/comment-moderation-responses.json similarity index 100% rename from tests/e2e-request-mocking/responses/OpenAI/comment-moderation-responses.json rename to tests/e2e-testing/responses/OpenAI/comment-moderation-responses.json diff --git a/tests/e2e-request-mocking/responses/OpenAI/completions.json b/tests/e2e-testing/responses/OpenAI/completions.json similarity index 100% rename from tests/e2e-request-mocking/responses/OpenAI/completions.json rename to tests/e2e-testing/responses/OpenAI/completions.json diff --git a/tests/e2e-request-mocking/responses/OpenAI/content-classification-completions.json b/tests/e2e-testing/responses/OpenAI/content-classification-completions.json similarity index 100% rename from tests/e2e-request-mocking/responses/OpenAI/content-classification-completions.json rename to tests/e2e-testing/responses/OpenAI/content-classification-completions.json diff --git a/tests/e2e-request-mocking/responses/OpenAI/content-classification-responses.json b/tests/e2e-testing/responses/OpenAI/content-classification-responses.json similarity index 100% rename from tests/e2e-request-mocking/responses/OpenAI/content-classification-responses.json rename to tests/e2e-testing/responses/OpenAI/content-classification-responses.json diff --git a/tests/e2e-request-mocking/responses/OpenAI/editorial-notes-completions.json b/tests/e2e-testing/responses/OpenAI/editorial-notes-completions.json similarity index 100% rename from tests/e2e-request-mocking/responses/OpenAI/editorial-notes-completions.json rename to tests/e2e-testing/responses/OpenAI/editorial-notes-completions.json diff --git a/tests/e2e-request-mocking/responses/OpenAI/editorial-notes-responses.json b/tests/e2e-testing/responses/OpenAI/editorial-notes-responses.json similarity index 100% rename from tests/e2e-request-mocking/responses/OpenAI/editorial-notes-responses.json rename to tests/e2e-testing/responses/OpenAI/editorial-notes-responses.json diff --git a/tests/e2e-request-mocking/responses/OpenAI/editorial-updates-completions.json b/tests/e2e-testing/responses/OpenAI/editorial-updates-completions.json similarity index 100% rename from tests/e2e-request-mocking/responses/OpenAI/editorial-updates-completions.json rename to tests/e2e-testing/responses/OpenAI/editorial-updates-completions.json diff --git a/tests/e2e-request-mocking/responses/OpenAI/editorial-updates-responses.json b/tests/e2e-testing/responses/OpenAI/editorial-updates-responses.json similarity index 100% rename from tests/e2e-request-mocking/responses/OpenAI/editorial-updates-responses.json rename to tests/e2e-testing/responses/OpenAI/editorial-updates-responses.json diff --git a/tests/e2e-request-mocking/responses/OpenAI/image.json b/tests/e2e-testing/responses/OpenAI/image.json similarity index 100% rename from tests/e2e-request-mocking/responses/OpenAI/image.json rename to tests/e2e-testing/responses/OpenAI/image.json diff --git a/tests/e2e-request-mocking/responses/OpenAI/models.json b/tests/e2e-testing/responses/OpenAI/models.json similarity index 100% rename from tests/e2e-request-mocking/responses/OpenAI/models.json rename to tests/e2e-testing/responses/OpenAI/models.json diff --git a/tests/e2e-request-mocking/responses/OpenAI/responses.json b/tests/e2e-testing/responses/OpenAI/responses.json similarity index 100% rename from tests/e2e-request-mocking/responses/OpenAI/responses.json rename to tests/e2e-testing/responses/OpenAI/responses.json diff --git a/tests/e2e/config/global-setup.ts b/tests/e2e/config/global-setup.ts index dede95fc3..4f225a379 100644 --- a/tests/e2e/config/global-setup.ts +++ b/tests/e2e/config/global-setup.ts @@ -28,7 +28,7 @@ async function globalSetup( config: FullConfig ) { // Reset the test environment before running the tests. await Promise.all( [ requestUtils.activateTheme( 'twentytwentyone' ), - requestUtils.activatePlugin( 'e2e-test-request-mocking' ), + requestUtils.activatePlugin( 'e2e-testing' ), requestUtils.deleteAllPosts(), requestUtils.deleteAllBlocks(), requestUtils.resetPreferences(), diff --git a/tests/e2e/specs/abilities/core-settings.spec.js b/tests/e2e/specs/abilities/core-settings.spec.js new file mode 100644 index 000000000..3f253460e --- /dev/null +++ b/tests/e2e/specs/abilities/core-settings.spec.js @@ -0,0 +1,129 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +/** + * Internal dependencies + */ +const { + enableExperiment, + enableExperiments, +} = require( '../../utils/helpers' ); + +/** + * Runs the `core/settings` ability through the client-side Abilities API, exactly + * as a consumer would in the browser. + * + * Mirrors the plugin's own sequence in `src/utils/run-ability.ts`: importing + * `@wordpress/core-abilities` initializes the client store (WordPress core's build + * runs `initialize()` on load and exports the resulting `ready` promise), so we + * await `ready` before calling `executeAbility` from `@wordpress/abilities`. + * + * The client modules are only present in the page's import map once an AI experiment + * is enabled in the block editor (it declares them as `module_dependencies`), which is + * set up in `beforeEach`. + * + * @param {import('@playwright/test').Page} page The Playwright page. + * @param {Object} input The ability input. + * @return {Promise} `{ ok: true, result }` or `{ ok: false, code }`. + */ +async function runCoreSettings( page, input ) { + return page.evaluate( async ( abilityInput ) => { + const { ready } = await import( '@wordpress/core-abilities' ); + if ( ready ) { + await ready; + } + + const { executeAbility } = await import( '@wordpress/abilities' ); + + try { + const result = await executeAbility( + 'core/settings', + abilityInput + ); + return { ok: true, result }; + } catch ( e ) { + return { ok: false, code: e && e.code ? e.code : null }; + } + }, input ); +} + +test.describe( 'core/settings ability (client-side Abilities API)', () => { + test.beforeEach( async ( { admin, page } ) => { + // Enabling an experiment loads its block-editor script, which declares the + // `@wordpress/abilities` + `@wordpress/core-abilities` modules as dependencies + // and so adds them to the editor's import map. + await enableExperiments( admin, page ); + await enableExperiment( admin, page, 'Excerpt Generation' ); + + // Run from the block editor, where the abilities client modules are available. + await admin.createNewPost( { + postType: 'post', + title: 'core/settings ability test', + } ); + } ); + + test( 'returns a flat, correctly typed map of settings', async ( { + page, + } ) => { + const outcome = await runCoreSettings( page, {} ); + + expect( outcome.ok ).toBe( true ); + // Flat map keyed by setting name (not grouped/nested). + expect( typeof outcome.result.blogname ).toBe( 'string' ); + expect( typeof outcome.result.posts_per_page ).toBe( 'number' ); + expect( typeof outcome.result.use_smilies ).toBe( 'boolean' ); + } ); + + test( 'filters by group', async ( { page } ) => { + const outcome = await runCoreSettings( page, { group: 'reading' } ); + + expect( outcome.ok ).toBe( true ); + expect( outcome.result ).toHaveProperty( 'posts_per_page' ); + // Settings from other groups must not leak in. + expect( outcome.result ).not.toHaveProperty( 'blogname' ); + expect( outcome.result ).not.toHaveProperty( 'use_smilies' ); + } ); + + test( 'filters by fields', async ( { page } ) => { + const outcome = await runCoreSettings( page, { + fields: [ 'blogname', 'posts_per_page' ], + } ); + + expect( outcome.ok ).toBe( true ); + expect( Object.keys( outcome.result ).sort() ).toEqual( [ + 'blogname', + 'posts_per_page', + ] ); + } ); + + test( 'combines group and fields filters (intersection)', async ( { + page, + } ) => { + // `blogname` is in the `general` group and `posts_per_page` in `reading`; only the + // latter satisfies both filters. + const outcome = await runCoreSettings( page, { + group: 'reading', + fields: [ 'blogname', 'posts_per_page' ], + } ); + + expect( outcome.ok ).toBe( true ); + expect( Object.keys( outcome.result ) ).toEqual( [ 'posts_per_page' ] ); + } ); + + test( 'exposes a setting registered by another active plugin', async ( { + page, + } ) => { + // Registered by the `e2e-testing` plugin (mapped in .wp-env.test.json) + // with `show_in_abilities` and a default of `sample-default`. + const outcome = await runCoreSettings( page, { + fields: [ 'ai_e2e_sample_setting' ], + } ); + + expect( outcome.ok ).toBe( true ); + expect( outcome.result ).toEqual( { + ai_e2e_sample_setting: 'sample-default', + } ); + } ); +} ); diff --git a/tests/e2e/specs/admin/settings.spec.js b/tests/e2e/specs/admin/settings.spec.js index 90f4e2dc5..9e2f56d52 100644 --- a/tests/e2e/specs/admin/settings.spec.js +++ b/tests/e2e/specs/admin/settings.spec.js @@ -29,11 +29,11 @@ const EXPERIMENT_GROUPS = { test.describe( 'Plugin settings', () => { test.beforeAll( async ( { requestUtils } ) => { - await requestUtils.deactivatePlugin( 'e2e-test-request-mocking' ); + await requestUtils.deactivatePlugin( 'e2e-testing' ); } ); test.afterAll( async ( { requestUtils } ) => { - await requestUtils.activatePlugin( 'e2e-test-request-mocking' ); + await requestUtils.activatePlugin( 'e2e-testing' ); await seedCredentials( requestUtils ); } ); @@ -43,7 +43,7 @@ test.describe( 'Plugin settings', () => { requestUtils, } ) => { // Activate the request mocking plugin. - await requestUtils.activatePlugin( 'e2e-test-request-mocking' ); + await requestUtils.activatePlugin( 'e2e-testing' ); // Clear out any existing Connectors. await clearConnectors( admin, page ); @@ -74,7 +74,7 @@ test.describe( 'Plugin settings', () => { requestUtils, } ) => { // Activate the request mocking plugin. - await requestUtils.activatePlugin( 'e2e-test-request-mocking' ); + await requestUtils.activatePlugin( 'e2e-testing' ); await visitConnectorsPage( admin ); diff --git a/tests/e2e/specs/experiments/ai-request-logging.spec.js b/tests/e2e/specs/experiments/ai-request-logging.spec.js index 093d87180..2b3d472f6 100644 --- a/tests/e2e/specs/experiments/ai-request-logging.spec.js +++ b/tests/e2e/specs/experiments/ai-request-logging.spec.js @@ -20,7 +20,7 @@ const PAGE_HEADING = 'AI Request Logs'; test.describe( 'AI Request Logging Experiment', () => { test.beforeAll( async ( { requestUtils } ) => { - await requestUtils.activatePlugin( 'e2e-test-request-mocking' ); + await requestUtils.activatePlugin( 'e2e-testing' ); } ); test( 'Can enable the experiment and reach the AI Request Logs page', async ( { diff --git a/tests/e2e/specs/experiments/content-resizing.spec.js b/tests/e2e/specs/experiments/content-resizing.spec.js index fbe169ec5..5f8780ca8 100644 --- a/tests/e2e/specs/experiments/content-resizing.spec.js +++ b/tests/e2e/specs/experiments/content-resizing.spec.js @@ -21,7 +21,7 @@ const SAMPLE_PARAGRAPH = 'This paragraph contains enough words for the resize toolbar to work against.'; // The mocked OpenAI response returns this string for the generic completions fixture -// (see tests/e2e-request-mocking/responses/OpenAI/completions.json). +// (see tests/e2e-testing/responses/OpenAI/completions.json). const MOCKED_RESPONSE = 'Edit or Delete Your First WordPress Post to Begin Your Blogging Adventure';