diff --git a/includes/Classifai/Features/DescriptiveTextGenerator.php b/includes/Classifai/Features/DescriptiveTextGenerator.php index 0002a4694..8242f64c1 100644 --- a/includes/Classifai/Features/DescriptiveTextGenerator.php +++ b/includes/Classifai/Features/DescriptiveTextGenerator.php @@ -3,6 +3,7 @@ namespace Classifai\Features; use Classifai\Providers\Azure\ComputerVision; +use Classifai\Providers\OpenAI\ChatGPT; use Classifai\Services\ImageProcessing; use WP_REST_Server; use WP_REST_Request; @@ -21,6 +22,13 @@ class DescriptiveTextGenerator extends Feature { */ const ID = 'feature_descriptive_text_generator'; + /** + * Prompt for generating descriptive text. + * + * @var string + */ + public $prompt = 'You are an assistant that generates descriptions of images that are used on a website. You will be provided with an image and will describe the main item you see in the image, giving details but staying concise. There is no need to say "the image contains" or similar, just describe what is actually in the image. This text will be important for screen readers, so make sure it is descriptive and accurate but not overly verbose.'; + /** * Constructor. */ @@ -33,6 +41,7 @@ public function __construct() { // Contains just the providers this feature supports. $this->supported_providers = [ ComputerVision::ID => __( 'Microsoft Azure AI Vision', 'classifai' ), + ChatGPT::ID => __( 'OpenAI', 'classifai' ), ]; } diff --git a/includes/Classifai/Providers/OpenAI/ChatGPT.php b/includes/Classifai/Providers/OpenAI/ChatGPT.php index 6a50aab76..7e126b11f 100644 --- a/includes/Classifai/Providers/OpenAI/ChatGPT.php +++ b/includes/Classifai/Providers/OpenAI/ChatGPT.php @@ -6,6 +6,7 @@ namespace Classifai\Providers\OpenAI; use Classifai\Features\ContentResizing; +use Classifai\Features\DescriptiveTextGenerator; use Classifai\Features\ExcerptGeneration; use Classifai\Features\TitleGeneration; use Classifai\Providers\Provider; @@ -14,6 +15,8 @@ use function Classifai\get_default_prompt; use function Classifai\sanitize_number_of_responses_field; +use function Classifai\get_modified_image_source_url; +use function Classifai\get_largest_size_and_dimensions_image_url; class ChatGPT extends Provider { @@ -132,6 +135,17 @@ public function get_default_provider_settings(): array { case TitleGeneration::ID: $common_settings['number_of_suggestions'] = 1; break; + + case DescriptiveTextGenerator::ID: + $common_settings['prompt'] = [ + [ + 'title' => esc_html__( 'ClassifAI default', 'classifai' ), + 'prompt' => $this->feature_instance->prompt, + 'original' => 1, + 'default' => 1, + ], + ]; + break; } return $common_settings; @@ -189,6 +203,9 @@ public function rest_endpoint_callback( $post_id = 0, string $route_to_call = '' // Handle all of our routes. switch ( $route_to_call ) { + case 'descriptive_text': + $return = $this->generate_descriptive_text( $post_id, $args ); + break; case 'excerpt': $return = $this->generate_excerpt( $post_id, $args ); break; @@ -203,6 +220,130 @@ public function rest_endpoint_callback( $post_id = 0, string $route_to_call = '' return $return; } + /** + * Generate descriptive text of an image. + * + * @param int $post_id The attachment ID we're processing. + * @param array $args Optional arguments. + * @return string|WP_Error + */ + public function generate_descriptive_text( int $post_id = 0, array $args = [] ) { + // Check to be sure the attachment exists and is an image. + if ( ! wp_attachment_is_image( $post_id ) ) { + return new WP_Error( 'invalid', esc_html__( 'This attachment can\'t be processed.', 'classifai' ) ); + } + + $metadata = wp_get_attachment_metadata( $post_id ); + + if ( ! $metadata || ! is_array( $metadata ) ) { + return new WP_Error( 'invalid', esc_html__( 'No valid metadata found.', 'classifai' ) ); + } + + $image_url = get_modified_image_source_url( $post_id ); + + if ( empty( $image_url ) || ! filter_var( $image_url, FILTER_VALIDATE_URL ) ) { + if ( isset( $metadata['sizes'] ) && is_array( $metadata['sizes'] ) ) { + $image_url = get_largest_size_and_dimensions_image_url( + get_attached_file( $post_id ), + wp_get_attachment_url( $post_id ), + $metadata, + [ 512, 2000 ], + [ 512, 2000 ], + 100 * MB_IN_BYTES + ); + } else { + $image_url = wp_get_attachment_url( $post_id ); + } + } + + if ( empty( $image_url ) ) { + return new WP_Error( 'error', esc_html__( 'Valid image size not found. Make sure the image is bigger than 512x512px.', 'classifai' ) ); + } + + $feature = new DescriptiveTextGenerator(); + $settings = $feature->get_settings(); + + // These checks (and the one above) happen in the REST permission_callback, + // but we run them again here in case this method is called directly. + if ( empty( $settings ) || ( isset( $settings[ static::ID ]['authenticated'] ) && false === $settings[ static::ID ]['authenticated'] ) || ( ! $feature->is_feature_enabled() && ( ! defined( 'WP_CLI' ) || ! WP_CLI ) ) ) { + return new WP_Error( 'not_enabled', esc_html__( 'Descriptive text generation is disabled or OpenAI authentication failed. Please check your settings.', 'classifai' ) ); + } + + $request = new APIRequest( $settings[ static::ID ]['api_key'] ?? '', $feature->get_option_name() ); + + /** + * Filter the prompt we will send to ChatGPT. + * + * @since 3.2.0 + * @hook classifai_chatgpt_descriptive_text_prompt + * + * @param {string} $prompt Prompt we are sending to ChatGPT. + * @param {int} $post_id ID of attachment we are describing. + * + * @return {string} Prompt. + */ + $prompt = apply_filters( 'classifai_chatgpt_descriptive_text_prompt', get_default_prompt( $settings[ static::ID ]['prompt'] ?? [] ) ?? $feature->prompt, $post_id ); + + /** + * Filter the request body before sending to ChatGPT. + * + * @since 3.2.0 + * @hook classifai_chatgpt_descriptive_text_request_body + * + * @param {array} $body Request body that will be sent to ChatGPT. + * @param {int} $post_id ID of attachment we are describing. + * + * @return {array} Request body. + */ + $body = apply_filters( + 'classifai_chatgpt_descriptive_text_request_body', + [ + 'model' => $this->chatgpt_model, + 'messages' => [ + [ + 'role' => 'system', + 'content' => $prompt, + ], + [ + 'role' => 'user', + 'content' => [ + [ + 'type' => 'image_url', + 'image_url' => [ + 'url' => $image_url, + 'detail' => 'auto', + ], + ], + ], + ], + ], + 'temperature' => 0.2, + 'max_tokens' => 300, + ], + $post_id + ); + + // Make our API request. + $response = $request->post( + $this->chatgpt_url, + [ + 'body' => wp_json_encode( $body ), + ] + ); + + // Extract out the text response, if it exists. + if ( ! is_wp_error( $response ) && ! empty( $response['choices'] ) ) { + foreach ( $response['choices'] as $choice ) { + if ( isset( $choice['message'], $choice['message']['content'] ) ) { + // ChatGPT often adds quotes to strings, so remove those as well as extra spaces. + $response = sanitize_text_field( trim( $choice['message']['content'], ' "\'' ) ); + } + } + } + + return $response; + } + /** * Generate an excerpt using ChatGPT. * diff --git a/includes/Classifai/Services/ImageProcessing.php b/includes/Classifai/Services/ImageProcessing.php index 981f22f19..bd508ea17 100644 --- a/includes/Classifai/Services/ImageProcessing.php +++ b/includes/Classifai/Services/ImageProcessing.php @@ -55,6 +55,7 @@ public static function get_service_providers(): array { 'classifai_image_processing_service_providers', [ 'Classifai\Providers\Azure\ComputerVision', + 'Classifai\Providers\OpenAI\ChatGPT', 'Classifai\Providers\OpenAI\DallE', ] ); diff --git a/src/js/settings/components/feature-additional-settings/content-resizing.js b/src/js/settings/components/feature-additional-settings/content-resizing.js index a312112ab..51d1a9610 100644 --- a/src/js/settings/components/feature-additional-settings/content-resizing.js +++ b/src/js/settings/components/feature-additional-settings/content-resizing.js @@ -33,7 +33,7 @@ export const ContentResizingSettings = () => { > { + setPrompts={ ( prompts ) => { setFeatureSettings( { condense_text_prompt: prompts, } ); @@ -47,7 +47,7 @@ export const ContentResizingSettings = () => { > { + setPrompts={ ( prompts ) => { setFeatureSettings( { expand_text_prompt: prompts, } ); diff --git a/src/js/settings/components/feature-additional-settings/excerpt-generation.js b/src/js/settings/components/feature-additional-settings/excerpt-generation.js index df1cf535e..be1ee790b 100644 --- a/src/js/settings/components/feature-additional-settings/excerpt-generation.js +++ b/src/js/settings/components/feature-additional-settings/excerpt-generation.js @@ -29,7 +29,7 @@ export const ExcerptGenerationSettings = () => { ); const { excerptPostTypesOptions } = usePostTypes(); const { setFeatureSettings } = useDispatch( STORE_NAME ); - const setPromts = ( prompts ) => { + const setPrompts = ( prompts ) => { setFeatureSettings( { generate_excerpt_prompt: prompts, } ); @@ -46,7 +46,7 @@ export const ExcerptGenerationSettings = () => { > { const [ showConfirmDialog, setShowConfirmDialog ] = useState( false ); const [ activeIndex, setActiveIndex ] = useState( null ); - const { prompts = [], setPromts } = props; + const { prompts = [], setPrompts } = props; const placeholder = prompts?.filter( ( prompt ) => prompt.original )[ 0 ]?.prompt || ''; // Add a new prompt. const addPrompt = () => { - setPromts( [ + setPrompts( [ ...prompts, { default: 0, original: 0, prompt: '', title: '' }, ] ); @@ -42,7 +42,7 @@ export const PromptRepeater = ( props ) => { if ( prompt[ 0 ]?.default ) { prompts[ 0 ].default = 1; } - setPromts( [ ...prompts ] ); + setPrompts( [ ...prompts ] ); }; // Update prompt. @@ -60,7 +60,7 @@ export const PromptRepeater = ( props ) => { ...prompts[ index ], ...changes, }; - setPromts( [ ...prompts ] ); + setPrompts( [ ...prompts ] ); }; // Confirm dialog to remove prompt. diff --git a/src/js/settings/components/feature-additional-settings/title-generation.js b/src/js/settings/components/feature-additional-settings/title-generation.js index e696434e3..dc279bc73 100644 --- a/src/js/settings/components/feature-additional-settings/title-generation.js +++ b/src/js/settings/components/feature-additional-settings/title-generation.js @@ -23,7 +23,7 @@ export const TitleGenerationSettings = () => { select( STORE_NAME ).getFeatureSettings() ); const { setFeatureSettings } = useDispatch( STORE_NAME ); - const setPromts = ( prompts ) => { + const setPrompts = ( prompts ) => { setFeatureSettings( { generate_title_prompt: prompts, } ); @@ -39,7 +39,7 @@ export const TitleGenerationSettings = () => { > ); diff --git a/src/js/settings/components/provider-settings/openai-chatgpt.js b/src/js/settings/components/provider-settings/openai-chatgpt.js index 12cf8191b..1721dab71 100644 --- a/src/js/settings/components/provider-settings/openai-chatgpt.js +++ b/src/js/settings/components/provider-settings/openai-chatgpt.js @@ -12,6 +12,7 @@ import { __ } from '@wordpress/i18n'; import { SettingsRow } from '../settings-row'; import { STORE_NAME } from '../../data/store'; import { useFeatureContext } from '../feature-settings/context'; +import { PromptRepeater } from '../feature-additional-settings/prompt-repeater'; /** * Component for OpenAI ChatGPT Provider settings. @@ -32,6 +33,11 @@ export const OpenAIChatGPTSettings = ( { isConfigured = false } ) => { ); const { setProviderSettings } = useDispatch( STORE_NAME ); const onChange = ( data ) => setProviderSettings( providerName, data ); + const setPrompts = ( prompts ) => { + setProviderSettings( providerName, { + prompt: prompts, + } ); + }; const Description = () => ( <> @@ -82,6 +88,22 @@ export const OpenAIChatGPTSettings = ( { isConfigured = false } ) => { /> ) } + { [ 'feature_descriptive_text_generator' ].includes( + featureName + ) && ( + + + + ) } ); }; diff --git a/tests/Classifai/Providers/Azure/ComputerVisionTest.php b/tests/Classifai/Providers/Azure/ComputerVisionTest.php index e20a2e9c9..d08eba908 100644 --- a/tests/Classifai/Providers/Azure/ComputerVisionTest.php +++ b/tests/Classifai/Providers/Azure/ComputerVisionTest.php @@ -60,40 +60,6 @@ public function test_smart_crop_image() { remove_filter( 'classifai_should_smart_crop_image', '__return_true' ); } - /** - * Ensure that settings returns default settings array if the `classifai_computer_vision` is not set. - */ - public function test_no_computer_vision_option_set() { - delete_option( 'classifai_computer_vision' ); - - $defaults = []; - - $expected = array_merge( - $defaults, - [ - 'status' => '0', - 'roles' => [], - 'users' => [], - 'user_based_opt_out' => 'no', - 'descriptive_text_fields' => [ - 'alt' => 'alt', - 'caption' => 0, - 'description' => 0, - ], - 'provider' => 'ms_computer_vision', - 'ms_computer_vision' => [ - 'endpoint_url' => '', - 'api_key' => '', - 'authenticated' => false, - 'descriptive_confidence_threshold' => 55, - ], - ] - ); - $settings = ( new \Classifai\Features\DescriptiveTextGenerator() )->get_settings(); - - $this->assertSame( $expected, $settings ); - } - /** * Ensure that attachment meta is being set. */ diff --git a/tests/cypress/integration/image-processing/image-processing-microsoft-azure.test.js b/tests/cypress/integration/image-processing/image-processing-microsoft-azure.test.js index 1d0fd3af1..7bfa6ecca 100644 --- a/tests/cypress/integration/image-processing/image-processing-microsoft-azure.test.js +++ b/tests/cypress/integration/image-processing/image-processing-microsoft-azure.test.js @@ -205,12 +205,15 @@ describe( 'Image processing Tests', () => { cy.disableFeatureForRoles( 'feature_descriptive_text_generator', [ 'administrator', ] ); + cy.wait( 500 ); cy.disableFeatureForRoles( 'feature_image_tags_generator', [ 'administrator', ] ); + cy.wait( 500 ); cy.disableFeatureForRoles( 'feature_image_cropping', [ 'administrator', ] ); + cy.wait( 500 ); cy.disableFeatureForRoles( 'feature_image_to_text_generator', [ 'administrator', ] ); @@ -223,12 +226,15 @@ describe( 'Image processing Tests', () => { cy.enableFeatureForRoles( 'feature_descriptive_text_generator', [ 'administrator', ] ); + cy.wait( 500 ); cy.enableFeatureForRoles( 'feature_image_tags_generator', [ 'administrator', ] ); + cy.wait( 500 ); cy.enableFeatureForRoles( 'feature_image_cropping', [ 'administrator', ] ); + cy.wait( 500 ); cy.enableFeatureForRoles( 'feature_image_to_text_generator', [ 'administrator', ] ); @@ -248,12 +254,15 @@ describe( 'Image processing Tests', () => { cy.disableFeatureForRoles( 'feature_descriptive_text_generator', [ 'administrator', ] ); + cy.wait( 500 ); cy.disableFeatureForRoles( 'feature_image_tags_generator', [ 'administrator', ] ); + cy.wait( 500 ); cy.disableFeatureForRoles( 'feature_image_cropping', [ 'administrator', ] ); + cy.wait( 500 ); cy.disableFeatureForRoles( 'feature_image_to_text_generator', [ 'administrator', ] ); @@ -265,8 +274,11 @@ describe( 'Image processing Tests', () => { cy.enableFeatureForUsers( 'feature_descriptive_text_generator', [ 'admin', ] ); + cy.wait( 500 ); cy.enableFeatureForUsers( 'feature_image_tags_generator', [ 'admin' ] ); + cy.wait( 500 ); cy.enableFeatureForUsers( 'feature_image_cropping', [ 'admin' ] ); + cy.wait( 500 ); cy.enableFeatureForUsers( 'feature_image_to_text_generator', [ 'admin', ] ); diff --git a/tests/cypress/integration/image-processing/image-processing-openai-chatgpt.test.js b/tests/cypress/integration/image-processing/image-processing-openai-chatgpt.test.js new file mode 100644 index 000000000..10b560105 --- /dev/null +++ b/tests/cypress/integration/image-processing/image-processing-openai-chatgpt.test.js @@ -0,0 +1,235 @@ +import { getChatGPTData } from '../../plugins/functions'; + +describe( 'OpenAI Image Processing Tests', () => { + let imageEditLink = ''; + let mediaModelLink = ''; + + before( () => { + cy.login(); + + const imageProcessingFeatures = [ + 'feature_descriptive_text_generator', + ]; + + imageProcessingFeatures.forEach( ( feature ) => { + cy.visitFeatureSettings( `image_processing/${ feature }` ); + cy.enableFeature(); + cy.selectProvider( 'openai_chatgpt' ); + cy.get( '#openai_chatgpt_api_key' ).clear().type( 'password' ); + cy.allowFeatureToAdmin(); + cy.get( '.classifai-settings__user-based-opt-out input' ).uncheck(); + + // Disable access for all users. + cy.disableFeatureForUsers(); + + cy.saveFeatureSettings(); + } ); + + cy.optInAllFeatures(); + } ); + + beforeEach( () => { + cy.login(); + } ); + + it( 'Can see Image Processing actions on edit media page and verify generated data.', () => { + cy.visitFeatureSettings( + 'image_processing/feature_descriptive_text_generator' + ); + cy.get( '.classifai-descriptive-text-fields input#alt' ).check(); + cy.saveFeatureSettings(); + cy.visit( '/wp-admin/upload.php?mode=grid' ); // Ensure grid mode is enabled. + cy.visit( '/wp-admin/media-new.php' ); + cy.get( '#plupload-upload-ui' ).should( 'exist' ); + cy.get( '#plupload-upload-ui input[type=file]' ).attachFile( + '../../../assets/img/onboarding-1.png' + ); + + cy.get( '#media-items .media-item a.edit-attachment', { + timeout: 20000, + } ).should( 'exist' ); + cy.get( '#media-items .media-item a.edit-attachment' ) + .invoke( 'attr', 'href' ) + .then( ( editLink ) => { + imageEditLink = editLink; + cy.visit( editLink ); + } ); + + // Verify Metabox with Image processing actions. + cy.get( '.postbox-header h2, #classifai_image_processing h2' ) + .first() + .contains( 'ClassifAI Image Processing' ); + cy.get( + '#classifai_image_processing label[for=rescan-captions]' + ).contains( 'No descriptive text? Rescan image' ); + + // Verify generated Data. + const imageData = getChatGPTData(); + cy.get( '#attachment_alt' ).should( 'have.value', imageData ); + } ); + + it( 'Can see Image Processing actions on media modal', () => { + const imageId = imageEditLink.split( 'post=' )[ 1 ]?.split( '&' )[ 0 ]; + mediaModelLink = `wp-admin/upload.php?item=${ imageId }`; + cy.visit( mediaModelLink ); + cy.get( '.media-modal' ).should( 'exist' ); + + // Verify Image processing actions. + cy.get( '#classifai-rescan-alt-tags' ).contains( 'Rescan' ); + } ); + + it( 'Can disable Image Processing features', () => { + const options = { + imageEditLink, + mediaModelLink, + }; + + // Disable features + cy.visitFeatureSettings( + 'image_processing/feature_descriptive_text_generator' + ); + cy.wait( 1000 ); + cy.get( '.classifai-descriptive-text-fields input#alt' ).uncheck(); + cy.get( '.classifai-descriptive-text-fields input#caption' ).uncheck(); + cy.get( + '.classifai-descriptive-text-fields input#description' + ).uncheck(); + cy.saveFeatureSettings(); + + // Verify that the feature is not available. + cy.wait( 1000 ); + cy.visit( options.imageEditLink ); + cy.get( + '#classifai_image_processing label[for=rescan-captions]' + ).should( 'not.exist' ); + cy.visit( options.mediaModelLink ); + cy.get( '.media-modal' ).should( 'exist' ); + cy.get( '#classifai-rescan-alt-tags' ).should( 'not.exist' ); + + // Enable features. + cy.visitFeatureSettings( + 'image_processing/feature_descriptive_text_generator' + ); + cy.get( '.classifai-descriptive-text-fields input#alt' ).check(); + cy.get( '.classifai-descriptive-text-fields input#caption' ).check(); + cy.get( + '.classifai-descriptive-text-fields input#description' + ).check(); + cy.wait( 1500 ); + cy.enableFeature(); + cy.saveFeatureSettings(); + + // Verify that the feature is available. + cy.wait( 1000 ); + cy.visit( options.imageEditLink ); + cy.get( + '#classifai_image_processing label[for=rescan-captions]' + ).should( 'exist' ); + cy.visit( options.mediaModelLink ); + cy.get( '.media-modal' ).should( 'exist' ); + cy.get( '#classifai-rescan-alt-tags' ).should( 'exist' ); + } ); + + it( 'Can enable/disable Image Processing features by roles', () => { + const options = { + imageEditLink, + mediaModelLink, + }; + + // Enable features. + cy.visitFeatureSettings( + 'image_processing/feature_descriptive_text_generator' + ); + cy.enableFeature(); + cy.get( '.classifai-descriptive-text-fields input#alt' ).check(); + cy.wait( 500 ); + cy.saveFeatureSettings(); + + // Disable access to admin role. + cy.disableFeatureForRoles( 'feature_descriptive_text_generator', [ + 'administrator', + ] ); + + // Verify that the feature is not available. + cy.wait( 1000 ); + cy.visit( options.imageEditLink ); + cy.get( + '#classifai_image_processing label[for=rescan-captions]' + ).should( 'not.exist' ); + cy.visit( options.mediaModelLink ); + cy.get( '.media-modal' ).should( 'exist' ); + cy.get( '#classifai-rescan-alt-tags' ).should( 'not.exist' ); + } ); + + it( 'Can enable/disable Image Processing features by user', () => { + const options = { + imageEditLink, + mediaModelLink, + }; + + // Disable access to admin role. + cy.disableFeatureForRoles( 'feature_descriptive_text_generator', [ + 'administrator', + ] ); + + // Verify that the feature is not available. + cy.wait( 1000 ); + cy.visit( options.imageEditLink ); + cy.get( + '#classifai_image_processing label[for=rescan-captions]' + ).should( 'not.exist' ); + cy.visit( options.mediaModelLink ); + cy.get( '.media-modal' ).should( 'exist' ); + cy.get( '#classifai-rescan-alt-tags' ).should( 'not.exist' ); + + cy.enableFeatureForUsers( 'feature_descriptive_text_generator', [ + 'admin', + ] ); + + // Verify that the feature is available. + cy.wait( 1000 ); + cy.visit( options.imageEditLink ); + cy.get( + '#classifai_image_processing label[for=rescan-captions]' + ).should( 'exist' ); + cy.visit( options.mediaModelLink ); + cy.get( '.media-modal' ).should( 'exist' ); + cy.get( '#classifai-rescan-alt-tags' ).should( 'exist' ); + } ); + + it( 'User can opt-out of Image Processing features', () => { + const options = { + imageEditLink, + mediaModelLink, + }; + + // Enable user based opt-out. + cy.enableFeatureOptOut( 'feature_descriptive_text_generator' ); + + // opt-out + cy.optOutFeature( 'feature_descriptive_text_generator' ); + + // Verify that the feature is not available. + cy.wait( 1000 ); + cy.visit( options.imageEditLink ); + cy.get( + '#classifai_image_processing label[for=rescan-captions]' + ).should( 'not.exist' ); + cy.visit( options.mediaModelLink ); + cy.get( '.media-modal' ).should( 'exist' ); + cy.get( '#classifai-rescan-alt-tags' ).should( 'not.exist' ); + + // opt-in + cy.optInFeature( 'feature_descriptive_text_generator' ); + + // Verify that the feature is available. + cy.wait( 1000 ); + cy.visit( options.imageEditLink ); + cy.get( + '#classifai_image_processing label[for=rescan-captions]' + ).should( 'exist' ); + cy.visit( options.mediaModelLink ); + cy.get( '.media-modal' ).should( 'exist' ); + cy.get( '#classifai-rescan-alt-tags' ).should( 'exist' ); + } ); +} ); diff --git a/tests/cypress/support/commands.js b/tests/cypress/support/commands.js index 4bc718ac9..40b187f84 100644 --- a/tests/cypress/support/commands.js +++ b/tests/cypress/support/commands.js @@ -188,6 +188,7 @@ Cypress.Commands.add( 'disableFeatureForRoles', ( feature, roles ) => { // Disable access for all users. cy.disableFeatureForUsers(); + cy.wait( 100 ); cy.saveFeatureSettings(); } );