diff --git a/phpstan.neon b/phpstan.neon
index 979b34ac5a..2b087e5511 100644
--- a/phpstan.neon
+++ b/phpstan.neon
@@ -14,8 +14,8 @@ parameters:
- vendor/php-stubs/wordpress-tests-stubs/wordpress-tests-stubs.php
type_coverage:
return_type: 87.6
- param_type: 79.1
- property_type: 0 # We can't use typed properties until PHP 7.4 becomes the plugin's minimum version. https://php.watch/versions/7.4/typed-properties
+ param_type: 79
+ property_type: 1.4
constant_type: 0 # We can't use typed constants until PHP 8.3 becomes the plugin's minimum version. https://php.watch/versions/8.3/typed-constants
print_suggestions: false
typeAliases:
diff --git a/src/UI/class-settings-page.php b/src/UI/class-settings-page.php
index 315e5ac06f..e0bc14a4b5 100644
--- a/src/UI/class-settings-page.php
+++ b/src/UI/class-settings-page.php
@@ -62,6 +62,7 @@
* custom_taxonomy_section?: string,
* cats_as_tags?: bool|string,
* content_helper: Parsely_Settings_Options_Content_Helper,
+ * headline_testing?: Parsely_Options_Headline_Testing,
* lowercase_tags?: bool,
* force_https_canonicals?: bool,
* disable_autotrack?: bool|string,
@@ -81,6 +82,7 @@
* }
*
* @phpstan-import-type Parsely_Options from Parsely
+ * @phpstan-import-type Parsely_Options_Headline_Testing from Parsely
*/
final class Settings_Page {
/**
@@ -262,6 +264,7 @@ public function initialize_settings(): void {
$this->initialize_basic_section();
$this->initialize_content_helper_section();
+ $this->initialize_headline_testing_section();
$this->initialize_recrawl_section();
$this->initialize_advanced_section();
}
@@ -430,7 +433,7 @@ private function initialize_basic_section(): void {
}
/**
- * Registers the Content Intelligence section and its settings.
+ * Registers section and settings for Content Intelligence section.
*
* @since 3.16.0
*/
@@ -528,6 +531,138 @@ private function initialize_content_helper_section(): void {
);
}
+ /**
+ * Registers section and settings for Headline Testing section.
+ *
+ * @since 3.21.0
+ */
+ private function initialize_headline_testing_section(): void {
+ $section_key = 'headline-testing-section';
+
+ add_settings_section(
+ $section_key,
+ __( 'Headline Testing', 'wp-parsely' ),
+ function (): void {
+ echo '
' . esc_html__( 'Configure Parse.ly Headline Testing to automatically test different headline variations and optimize for engagement.', 'wp-parsely' ) . '
';
+ echo '' . esc_html__( 'Learn more about Headline Testing', 'wp-parsely' ) . '
';
+ },
+ Parsely::MENU_SLUG
+ );
+
+ // Enable Headline Testing.
+ $field_id = 'headline_testing[enabled]';
+ $field_args = array(
+ 'option_key' => $field_id,
+ 'label_for' => $field_id,
+ 'yes_text' => __( 'Enabled', 'wp-parsely' ),
+ 'add_fieldset' => true,
+ 'legend' => __( 'Headline Testing', 'wp-parsely' ),
+ 'help_text' => __( 'Enable Parse.ly Headline Testing to automatically test different headline variations.', 'wp-parsely' ),
+ );
+ add_settings_field(
+ $field_id,
+ __( 'Headline Testing', 'wp-parsely' ),
+ array( $this, 'print_checkbox_tag' ),
+ Parsely::MENU_SLUG,
+ $section_key,
+ $field_args
+ );
+
+ // Installation Method.
+ $field_id = 'headline_testing[installation_method]';
+ $field_args = array(
+ 'option_key' => $field_id,
+ 'label_for' => $field_id,
+ 'help_text' => __( 'Choose how you want to install the Headline Testing script. One-line snippet is recommended for most sites.', 'wp-parsely' ),
+ 'radio_options' => array(
+ 'one_line' => __( 'One-line Snippet (Recommended)', 'wp-parsely' ),
+ 'advanced' => __( 'Advanced Installation', 'wp-parsely' ),
+ ),
+ );
+ add_settings_field(
+ $field_id,
+ __( 'Installation Method', 'wp-parsely' ),
+ array( $this, 'print_radio_tags' ),
+ Parsely::MENU_SLUG,
+ $section_key,
+ $field_args
+ );
+
+ // Enable Flicker Control (Advanced only).
+ $field_id = 'headline_testing[enable_flicker_control]';
+ $field_args = array(
+ 'option_key' => $field_id,
+ 'label_for' => $field_id,
+ 'yes_text' => __( 'Enabled', 'wp-parsely' ),
+ 'help_text' => __( 'Hide page body for up to 500ms to prevent flickering when headlines are replaced. Only available with Advanced installation.', 'wp-parsely' ),
+ );
+ add_settings_field(
+ $field_id,
+ __( 'Enable Flicker Control', 'wp-parsely' ),
+ array( $this, 'print_checkbox_tag' ),
+ Parsely::MENU_SLUG,
+ $section_key,
+ $field_args
+ );
+
+ // Enable Live Updates.
+ $field_id = 'headline_testing[enable_live_updates]';
+ $field_args = array(
+ 'option_key' => $field_id,
+ 'label_for' => $field_id,
+ 'yes_text' => __( 'Enabled', 'wp-parsely' ),
+ 'help_text' => __( 'Watch for new content and automatically update headlines for newly added anchors.', 'wp-parsely' ),
+ );
+ add_settings_field(
+ $field_id,
+ __( 'Enable Live Updates', 'wp-parsely' ),
+ array( $this, 'print_checkbox_tag' ),
+ Parsely::MENU_SLUG,
+ $section_key,
+ $field_args
+ );
+
+ // Live Update Timeout.
+ $field_id = 'headline_testing[live_update_timeout]';
+ $field_args = array(
+ 'option_key' => $field_id,
+ 'label_for' => $field_id,
+ 'help_text' => __( 'How long to watch for new content (in milliseconds). Default: 30000 (30 seconds).', 'wp-parsely' ),
+ 'optional_args' => array(
+ 'type' => 'number',
+ 'placeholder' => '30000',
+ 'min' => '1000',
+ 'max' => '60000',
+ 'step' => '1000',
+ ),
+ );
+ add_settings_field(
+ $field_id,
+ __( 'Live Update Timeout (ms)', 'wp-parsely' ),
+ array( $this, 'print_text_tag' ),
+ Parsely::MENU_SLUG,
+ $section_key,
+ $field_args
+ );
+
+ // Allow After Content Load.
+ $field_id = 'headline_testing[allow_after_content_load]';
+ $field_args = array(
+ 'option_key' => $field_id,
+ 'label_for' => $field_id,
+ 'yes_text' => __( 'Enabled', 'wp-parsely' ),
+ 'help_text' => __( 'Allow headline swapping even after the main content has loaded. May cause flickering. Highly recommended if you are loading your script asynchronously.', 'wp-parsely' ),
+ );
+ add_settings_field(
+ $field_id,
+ __( 'Allow After Content Load', 'wp-parsely' ),
+ array( $this, 'print_checkbox_tag' ),
+ Parsely::MENU_SLUG,
+ $section_key,
+ $field_args
+ );
+ }
+
/**
* Registers section and settings for Recrawl section.
*
@@ -836,25 +971,25 @@ private function print_description_text( $args ): void {
* @param Setting_Arguments $args The arguments for text tag.
*/
public function print_text_tag( $args ): void {
- $options = $this->parsely->get_options();
- $name = $args['option_key'];
- /**
- * Variable.
- *
- * @var string
- */
- $value = $options[ $name ] ?? '';
+ $options = $this->parsely->get_options();
+ $name = $args['option_key'];
+ $raw_value = $this->get_option_value( $name, $options );
+ $value_as_string = is_scalar( $raw_value ) ? (string) $raw_value : '';
$optional_args = $args['optional_args'] ?? array();
$id = esc_attr( $name );
- $name = Parsely::OPTIONS_KEY . "[$id]";
+ $html_name = $this->get_html_name_attribute( $name );
$is_obfuscated_value = $optional_args['is_obfuscated_value'] ?? false;
- $value = $is_obfuscated_value ? $this->get_obfuscated_value( $value ) : esc_attr( $value );
- $accepted_args = array( 'placeholder', 'required', 'disabled' );
+ $value = $is_obfuscated_value ? $this->get_obfuscated_value( $value_as_string ) : esc_attr( $value_as_string );
$type = $optional_args['type'] ?? 'text';
+ $accepted_args = array( 'placeholder', 'required', 'disabled' );
+
+ if ( 'number' === $type ) {
+ $accepted_args = array_merge( $accepted_args, array( 'min', 'max', 'step' ) );
+ }
$is_managed = key_exists( $id, $this->parsely->managed_options );
echo '' : '>';
- printf( " get_html_name_attribute( $name );
$yes_text = $args['yes_text'] ?? '';
-
- // Get option value.
- if ( false === strpos( $name, '[' ) ) {
- $value = $options[ $name ];
- } else {
- $value = Parsely::get_nested_option_value( $name, $options );
- }
+ $value = $this->get_option_value( $name, $options );
// Fieldset start.
if ( $has_fieldset ) {
@@ -1039,9 +1164,11 @@ public function print_select_tag( $args ): void {
* @param Setting_Arguments $args The arguments for the radio buttons.
*/
public function print_radio_tags( $args ): void {
+ $options = $this->parsely->get_options();
$name = $args['option_key'];
$id = esc_attr( $name );
- $selected = $this->parsely->get_options()[ $name ];
+ $selected = $this->get_option_value( $name, $options );
+ $html_name = $this->get_html_name_attribute( $name );
$title = $args['title'] ?? '';
$radio_options = $args['radio_options'] ?? array();
@@ -1059,7 +1186,7 @@ public function print_radio_tags( $args ): void {
">
"
+ name=""
id=""
value=""
@@ -1106,6 +1233,53 @@ public function print_media_single_image( $args ): void {
$this->print_description_text( $args );
}
+ /**
+ * Generates the HTML name attribute for a form field.
+ *
+ * Handles nested options (content_helper, headline_testing) and regular
+ * options, properly escaping and formatting the attribute value.
+ *
+ * @since 3.21.0
+ *
+ * @param string $name The option key/name.
+ * @return string The properly formatted and escaped HTML name attribute value.
+ */
+ private function get_html_name_attribute( string $name ): string {
+ if ( strpos( $name, 'content_helper' ) === 0 ) {
+ return Parsely::OPTIONS_KEY . str_replace(
+ 'content_helper',
+ '[content_helper]',
+ esc_attr( $name )
+ );
+ } elseif ( strpos( $name, 'headline_testing' ) === 0 ) {
+ return Parsely::OPTIONS_KEY . str_replace(
+ 'headline_testing',
+ '[headline_testing]',
+ esc_attr( $name )
+ );
+ } else {
+ return Parsely::OPTIONS_KEY . '[' . esc_attr( $name ) . ']';
+ }
+ }
+
+ /**
+ * Gets the value of an option, handling both flat and nested option keys.
+ *
+ * @since 3.21.0
+ *
+ * @param string $name The option key name (may contain brackets for nested options).
+ * @param Parsely_Options $options The options array to retrieve from.
+ * @return mixed The option value, or null if not found.
+ */
+ private function get_option_value( string $name, $options ) {
+ // Get raw value based on whether it's a nested option or not.
+ if ( false === strpos( $name, '[' ) ) {
+ return $options[ $name ] ?? null;
+ } else {
+ return Parsely::get_nested_option_value( $name, $options ) ?? null;
+ }
+ }
+
/**
* Prints out the post tracking options table.
*
@@ -1205,6 +1379,7 @@ public function get_tracking_values_for_display(): array {
public function validate_options( $input ) {
$input = $this->validate_basic_section( $input );
$input = $this->validate_content_helper_section( $input );
+ $input = $this->validate_headline_testing_section( $input );
$input = $this->validate_recrawl_section( $input );
$input = $this->validate_advanced_section( $input );
@@ -1390,6 +1565,80 @@ private function validate_content_helper_section( $input ) {
return $input;
}
+ /**
+ * Validates fields of Headline Testing Section.
+ *
+ * @since 3.21.0
+ *
+ * @param ParselySettingOptions $input Options from the settings page.
+ * @return ParselySettingOptions Validated inputs.
+ */
+ private function validate_headline_testing_section( $input ) {
+ /**
+ * Sanitizes the Headline Testing data.
+ *
+ * @since 3.21.0
+ */
+ $sanitize = function ( $input ) use ( &$sanitize ) {
+ foreach ( $input as $key => $value ) {
+ if ( is_array( $value ) ) {
+ // Recurse for nested arrays.
+ $input[ $key ] = $sanitize( $value );
+ } else {
+ $input[ $key ] = $this->sanitize_headline_testing_field( $key, $value );
+ }
+ }
+
+ return $input;
+ };
+
+ // Initialize headline_testing array if it doesn't exist.
+ if ( ! isset( $input['headline_testing'] ) ) {
+ $input['headline_testing'] = array();
+ }
+
+ // Ensure all required keys exist with defaults.
+ $input['headline_testing'] = array_merge(
+ $this->parsely->get_default_options()['headline_testing'],
+ $input['headline_testing']
+ );
+
+ // Produce the final array.
+ $options = $this->parsely->get_options()['headline_testing'];
+ $merged = array_merge( $options, $input['headline_testing'] );
+
+ $input['headline_testing'] = $sanitize( $merged );
+
+ return $input;
+ }
+
+ /**
+ * Sanitizes headline testing field values.
+ *
+ * @since 3.21.0
+ *
+ * @param string $key The field key.
+ * @param mixed $value The field value.
+ * @return mixed The sanitized value.
+ */
+ private function sanitize_headline_testing_field( string $key, $value ) {
+ switch ( $key ) {
+ case 'enabled':
+ case 'enable_flicker_control':
+ case 'enable_live_updates':
+ case 'allow_after_content_load':
+ return 'true' === $value || true === $value;
+ case 'installation_method':
+ $valid_methods = array( 'one_line', 'advanced' );
+ return in_array( $value, $valid_methods, true ) ? $value : 'one_line';
+ case 'live_update_timeout':
+ $timeout = intval( is_scalar( $value ) ? (string) $value : '0' );
+ return ( $timeout >= 1000 && $timeout <= 60000 ) ? $timeout : 30000;
+ default:
+ return sanitize_text_field( is_scalar( $value ) ? (string) $value : '' );
+ }
+ }
+
/**
* Validates fields of Recrawl Section.
*
diff --git a/src/class-headline-testing.php b/src/class-headline-testing.php
new file mode 100644
index 0000000000..ae38b58139
--- /dev/null
+++ b/src/class-headline-testing.php
@@ -0,0 +1,209 @@
+
+ */
+ private $data_attributes = array();
+
+ /**
+ * Constructor.
+ *
+ * @since 3.21.0
+ *
+ * @param Parsely $parsely Instance of Parsely class.
+ */
+ public function __construct( Parsely $parsely ) {
+ $this->parsely = $parsely;
+ }
+
+ /**
+ * Registers the Headline Testing feature.
+ *
+ * @since 3.21.0
+ */
+ public function run(): void {
+ if ( false === $this->can_enable_feature() ) {
+ return;
+ }
+
+ add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_headline_testing_script' ) );
+ }
+
+ /**
+ * Returns whether the Headline Testing feature can be enabled.
+ *
+ * @since 3.21.0
+ *
+ * @return bool True if the feature can be enabled, false otherwise.
+ */
+ public function can_enable_feature(): bool {
+ $options = $this->parsely->get_options();
+
+ return true === $options['headline_testing']['enabled'] &&
+ '' !== $this->parsely->get_site_id();
+ }
+
+ /**
+ * Enqueues the Headline Testing script, in accordance to the
+ * installation_method option.
+ *
+ * @since 3.21.0
+ */
+ public function enqueue_headline_testing_script(): void {
+ $options = $this->parsely->get_options();
+ $site_id = $this->parsely->get_site_id();
+
+ $headline_testing_options = $options['headline_testing'];
+ $installation_method = $headline_testing_options['installation_method'];
+
+ if ( 'one_line' === $installation_method ) {
+ $this->enqueue_one_line_script( $headline_testing_options, $site_id );
+ } else {
+ $this->enqueue_advanced_script( $headline_testing_options, $site_id );
+ }
+ }
+
+ /**
+ * Enqueues the one-line snippet script.
+ *
+ * @since 3.21.0
+ *
+ * @param Parsely_Options_Headline_Testing $options The headline testing options.
+ * @param string $site_id The Parse.ly site ID.
+ */
+ private function enqueue_one_line_script( $options, string $site_id ): void {
+ $script_url = 'https://experiments.parsely.com/vip-experiments.js?apiKey=' . rawurlencode( $site_id );
+
+ // Build data attributes string.
+ $data_attributes = array();
+
+ if ( $options['enable_live_updates'] ) {
+ $data_attributes[] = 'data-enable-live-updates="true"';
+
+ $timeout = absint( $options['live_update_timeout'] );
+ if ( 30000 !== $timeout ) {
+ $data_attributes[] = 'data-live-update-timeout="' . esc_attr( (string) $timeout ) . '"';
+ }
+ }
+
+ if ( $options['allow_after_content_load'] ) {
+ $data_attributes[] = 'data-allow-after-content-load="true"';
+ }
+
+ // Store data attributes and add filter to modify the script tag.
+ if ( count( $data_attributes ) > 0 ) {
+ $this->data_attributes = $data_attributes;
+
+ if ( false === has_filter(
+ 'script_loader_tag',
+ array( $this, 'add_data_attributes_to_script_tag' )
+ ) ) {
+ add_filter(
+ 'script_loader_tag',
+ array( $this, 'add_data_attributes_to_script_tag' ),
+ 10,
+ 2
+ );
+ }
+ }
+
+ // Register and enqueue the script.
+ wp_register_script(
+ 'parsely-headline-testing-one-line',
+ $script_url,
+ array(),
+ PARSELY_VERSION,
+ false
+ );
+
+ wp_enqueue_script( 'parsely-headline-testing-one-line' );
+ }
+
+ /**
+ * Adds data attributes to the one-line script tag.
+ *
+ * @since 3.21.0
+ *
+ * @param string $tag The script tag.
+ * @param string $handle The script handle.
+ * @return string The modified script tag.
+ */
+ public function add_data_attributes_to_script_tag( string $tag, string $handle ): string {
+ if ( 'parsely-headline-testing-one-line' === $handle ) {
+ // Insert data attributes before the closing > of the script tag.
+ $tag = str_replace( '>', ' ' . implode( ' ', $this->data_attributes ) . '>', $tag );
+ }
+
+ return $tag;
+ }
+
+ /**
+ * Enqueues the advanced installation script.
+ *
+ * @since 3.21.0
+ *
+ * @param Parsely_Options_Headline_Testing $options The headline testing options.
+ * @param string $site_id The Parse.ly site ID.
+ */
+ private function enqueue_advanced_script( $options, string $site_id ): void {
+ $config_options = array();
+
+ if ( $options['enable_flicker_control'] ) {
+ $config_options[] = 'enableFlickerControl: true';
+ }
+
+ if ( $options['enable_live_updates'] ) {
+ $config_options[] = 'enableLiveUpdates: true';
+
+ $timeout = absint( $options['live_update_timeout'] );
+ $config_options[] = 'liveUpdateTimeout: ' . $timeout;
+ }
+
+ if ( $options['allow_after_content_load'] ) {
+ $config_options[] = 'allowAfterContentLoad: true';
+ }
+
+ $config_str = count( $config_options ) > 0 ? ', {' . implode( ', ', $config_options ) . '}' : '';
+
+ $script_content = '!function(){"use strict";var e=window.VIP_EXP=window.VIP_EXP||{config:{}};e.loadVIPExp=function(t){var n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};t&&(e.config=n,e.config.apikey=t,function(e){if(!e)return;var t="https://experiments.parsely.com/vip-experiments.js"+"?apiKey=".concat(e),n=document.createElement("script");n.src=t,n.type="text/javascript",n.fetchPriority="high";var i=document.getElementsByTagName("script")[0];i&&i.parentNode&&i.parentNode.insertBefore(n,i)}(t),n.enableFlickerControl&&function(){var t,n;if(null!==(t=performance)&&void 0!==t&&null!==(n=t.getEntriesByName)&&void 0!==n&&null!==(n=n.call(t,"first-contentful-paint"))&&void 0!==n&&n[0])return;var i="vipexp-fooc-prevention";e.config.disableFlickerControl=function(){var e=document.getElementById(i);null!=e&&e.parentNode&&e.parentNode.removeChild(e)};var o=document.createElement("style");o.setAttribute("type","text/css"),o.appendChild(document.createTextNode("body { visibility: hidden; }")),o.id=i,document.head.appendChild(o),window.setTimeout(e.config.disableFlickerControl,500)}())},e.loadVIPExp("' . esc_js( $site_id ) . '"' . $config_str . ')}();';
+
+ // Register and enqueue the inline script.
+ wp_register_script(
+ 'parsely-headline-testing-advanced',
+ '',
+ array(),
+ PARSELY_VERSION,
+ false
+ );
+
+ wp_add_inline_script( 'parsely-headline-testing-advanced', $script_content );
+ wp_enqueue_script( 'parsely-headline-testing-advanced' );
+ }
+}
diff --git a/src/class-parsely.php b/src/class-parsely.php
index 091a1f24c0..239cd18ab3 100644
--- a/src/class-parsely.php
+++ b/src/class-parsely.php
@@ -32,6 +32,7 @@
* custom_taxonomy_section: string,
* cats_as_tags: bool,
* content_helper: Parsely_Options_Content_Helper,
+ * headline_testing: Parsely_Options_Headline_Testing,
* track_authenticated_users: bool,
* lowercase_tags: bool,
* force_https_canonicals: bool,
@@ -61,6 +62,15 @@
* allowed_user_roles: string[],
* }
*
+ * @phpstan-type Parsely_Options_Headline_Testing array{
+ * enabled: bool,
+ * installation_method: string,
+ * enable_flicker_control: bool,
+ * enable_live_updates: bool,
+ * live_update_timeout: int,
+ * allow_after_content_load: bool,
+ * }
+ *
* @phpstan-type WP_HTTP_Request_Args array{
* method?: string,
* timeout?: float,
@@ -136,6 +146,14 @@ class Parsely {
'allowed_user_roles' => array( 'administrator' ),
),
),
+ 'headline_testing' => array(
+ 'enabled' => false,
+ 'installation_method' => 'one_line',
+ 'enable_flicker_control' => false,
+ 'enable_live_updates' => false,
+ 'live_update_timeout' => 30000,
+ 'allow_after_content_load' => false,
+ ),
'track_authenticated_users' => false,
'lowercase_tags' => true,
'force_https_canonicals' => false,
diff --git a/src/rest-api/settings/class-endpoint-headline-testing-settings.php b/src/rest-api/settings/class-endpoint-headline-testing-settings.php
new file mode 100644
index 0000000000..1312cc9311
--- /dev/null
+++ b/src/rest-api/settings/class-endpoint-headline-testing-settings.php
@@ -0,0 +1,80 @@
+
+ */
+ protected function get_subvalues_specs(): array {
+ return array(
+ 'enabled' => array(
+ 'values' => array( true, false ),
+ 'default' => false,
+ ),
+ 'installation_method' => array(
+ 'values' => array( 'one_line', 'advanced' ),
+ 'default' => 'one_line',
+ ),
+ 'enable_flicker_control' => array(
+ 'values' => array( true, false ),
+ 'default' => false,
+ ),
+ 'enable_live_updates' => array(
+ 'values' => array( true, false ),
+ 'default' => false,
+ ),
+ 'live_update_timeout' => array(
+ 'values' => range( 1000, 60000, 1000 ),
+ 'default' => 30000,
+ ),
+ 'allow_after_content_load' => array(
+ 'values' => array( true, false ),
+ 'default' => false,
+ ),
+ );
+ }
+}
diff --git a/src/rest-api/settings/class-settings-controller.php b/src/rest-api/settings/class-settings-controller.php
index a7a37381db..d24ab4bbd0 100644
--- a/src/rest-api/settings/class-settings-controller.php
+++ b/src/rest-api/settings/class-settings-controller.php
@@ -41,6 +41,7 @@ public function init(): void {
new Endpoint_Dashboard_Widget_Settings( $this ),
new Endpoint_Editor_Sidebar_Settings( $this ),
new Endpoint_Traffic_Boost_Settings( $this ),
+ new Endpoint_Headline_Testing_Settings( $this ),
);
$this->register_endpoints( $endpoints );
diff --git a/tests/Integration/HeadlineTestingScriptsTest.php b/tests/Integration/HeadlineTestingScriptsTest.php
new file mode 100644
index 0000000000..c38caf1e53
--- /dev/null
+++ b/tests/Integration/HeadlineTestingScriptsTest.php
@@ -0,0 +1,487 @@
+run();
+ self::assertFalse(
+ has_action(
+ 'wp_enqueue_scripts',
+ array( self::$headline_testing, 'enqueue_headline_testing_script' )
+ )
+ );
+ }
+
+ /**
+ * Verifies that the action gets registered when the the feature is enabled.
+ *
+ * @since 3.21.0
+ *
+ * @covers \Parsely\Headline_Testing::can_enable_feature
+ * @covers \Parsely\Headline_Testing::enqueue_headline_testing_script
+ * @covers \Parsely\Headline_Testing::enqueue_one_line_script
+ * @covers \Parsely\Headline_Testing::run
+ * @uses \Parsely\Headline_Testing::__construct
+ * @uses \Parsely\Parsely::__construct
+ * @uses \Parsely\Parsely::allow_parsely_remote_requests
+ * @uses \Parsely\Parsely::are_credentials_managed
+ * @uses \Parsely\Parsely::get_managed_credentials
+ * @uses \Parsely\Parsely::get_options
+ * @uses \Parsely\Parsely::get_site_id
+ * @uses \Parsely\Parsely::set_default_content_helper_settings_values
+ * @uses \Parsely\Parsely::set_default_full_metadata_in_non_posts
+ * @uses \Parsely\Parsely::set_managed_options
+ * @uses \Parsely\Parsely::site_id_is_set
+ * @uses \Parsely\Permissions::build_pch_permissions_settings_array
+ * @uses \Parsely\Permissions::get_user_roles_with_edit_posts_cap
+ * @uses \Parsely\Services\Content_API\Content_API_Service::get_base_url
+ * @uses \Parsely\Services\Suggestions_API\Suggestions_API_Service::get_base_url
+ */
+ public function test_register_action_when_enabled_option_is_on(): void {
+ TestCase::set_options( array( 'headline_testing' => array( 'enabled' => true ) ) );
+ self::$headline_testing->run();
+
+ self::assertSame(
+ 10,
+ has_action(
+ 'wp_enqueue_scripts',
+ array( self::$headline_testing, 'enqueue_headline_testing_script' )
+ )
+ );
+ }
+
+ /**
+ * Verifies that run() does not register the action when no Site ID is set.
+ *
+ * @since 3.21.0
+ *
+ * @covers \Parsely\Headline_Testing::can_enable_feature
+ * @covers \Parsely\Headline_Testing::run
+ * @uses \Parsely\Headline_Testing::__construct
+ * @uses \Parsely\Parsely::__construct
+ * @uses \Parsely\Parsely::allow_parsely_remote_requests
+ * @uses \Parsely\Parsely::are_credentials_managed
+ * @uses \Parsely\Parsely::get_managed_credentials
+ * @uses \Parsely\Parsely::get_options
+ * @uses \Parsely\Parsely::get_site_id
+ * @uses \Parsely\Parsely::set_default_content_helper_settings_values
+ * @uses \Parsely\Parsely::set_default_full_metadata_in_non_posts
+ * @uses \Parsely\Parsely::set_managed_options
+ * @uses \Parsely\Parsely::site_id_is_set
+ * @uses \Parsely\Permissions::build_pch_permissions_settings_array
+ * @uses \Parsely\Permissions::get_user_roles_with_edit_posts_cap
+ * @uses \Parsely\Services\Content_API\Content_API_Service::get_base_url
+ * @uses \Parsely\Services\Suggestions_API\Suggestions_API_Service::get_base_url
+ */
+ public function test_do_not_register_action_when_no_site_id_is_set(): void {
+ TestCase::set_options(
+ array(
+ 'apikey' => '',
+ 'headline_testing' => array( 'enabled' => true ),
+ )
+ );
+
+ self::$headline_testing->run();
+
+ // Should not be enqueued as the Site ID is empty.
+ self::assertFalse(
+ has_action(
+ 'wp_enqueue_scripts',
+ array( self::$headline_testing, 'enqueue_headline_testing_script' )
+ )
+ );
+ }
+
+ /**
+ * Verifies that enqueuing functionality works as expected.
+ *
+ * @since 3.21.0
+ *
+ * @covers \Parsely\Headline_Testing::can_enable_feature
+ * @covers \Parsely\Headline_Testing::enqueue_headline_testing_script
+ * @covers \Parsely\Headline_Testing::enqueue_one_line_script
+ * @covers \Parsely\Headline_Testing::run
+ * @uses \Parsely\Headline_Testing::__construct
+ * @uses \Parsely\Parsely::__construct
+ * @uses \Parsely\Parsely::allow_parsely_remote_requests
+ * @uses \Parsely\Parsely::are_credentials_managed
+ * @uses \Parsely\Parsely::get_default_options
+ * @uses \Parsely\Parsely::get_managed_credentials
+ * @uses \Parsely\Parsely::get_options
+ * @uses \Parsely\Parsely::get_site_id
+ * @uses \Parsely\Parsely::set_default_content_helper_settings_values
+ * @uses \Parsely\Parsely::set_default_full_metadata_in_non_posts
+ * @uses \Parsely\Parsely::set_managed_options
+ * @uses \Parsely\Parsely::site_id_is_set
+ * @uses \Parsely\Permissions::build_pch_permissions_settings_array
+ * @uses \Parsely\Permissions::get_user_roles_with_edit_posts_cap
+ * @uses \Parsely\Services\Content_API\Content_API_Service::get_base_url
+ * @uses \Parsely\Services\Suggestions_API\Suggestions_API_Service::get_base_url
+ */
+ public function test_script_is_registered_and_enqueued(): void {
+ $this->enable_headline_testing();
+
+ self::$headline_testing->run();
+
+ // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
+ do_action( 'wp_enqueue_scripts' );
+
+ // Should be enqueued as the headline testing option is enabled and the Site ID is set.
+ $this->assert_is_script_registered( 'parsely-headline-testing-one-line' );
+ $this->assert_is_script_enqueued( 'parsely-headline-testing-one-line' );
+ }
+
+ /**
+ * Verifies that the HTML markup is correctly output when using the one-line
+ * script.
+ *
+ * @since 3.21.0
+ *
+ * @covers \Parsely\Headline_Testing::can_enable_feature
+ * @covers \Parsely\Headline_Testing::enqueue_headline_testing_script
+ * @covers \Parsely\Headline_Testing::enqueue_one_line_script
+ * @covers \Parsely\Headline_Testing::run
+ * @uses \Parsely\Headline_Testing::__construct
+ * @uses \Parsely\Parsely::__construct
+ * @uses \Parsely\Parsely::allow_parsely_remote_requests
+ * @uses \Parsely\Parsely::are_credentials_managed
+ * @uses \Parsely\Parsely::get_default_options
+ * @uses \Parsely\Parsely::get_managed_credentials
+ * @uses \Parsely\Parsely::get_options
+ * @uses \Parsely\Parsely::get_site_id
+ * @uses \Parsely\Parsely::set_default_content_helper_settings_values
+ * @uses \Parsely\Parsely::set_default_full_metadata_in_non_posts
+ * @uses \Parsely\Parsely::set_managed_options
+ * @uses \Parsely\Parsely::site_id_is_set
+ * @uses \Parsely\Permissions::build_pch_permissions_settings_array
+ * @uses \Parsely\Permissions::get_user_roles_with_edit_posts_cap
+ * @uses \Parsely\Services\Content_API\Content_API_Service::get_base_url
+ * @uses \Parsely\Services\Suggestions_API\Suggestions_API_Service::get_base_url
+ */
+ public function test_markup_with_one_line_script_installation_method(): void {
+ $this->enable_headline_testing();
+
+ self::$headline_testing->run();
+
+ // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
+ do_action( 'wp_enqueue_scripts' );
+
+ ob_start();
+ wp_print_scripts();
+ $output = (string) ob_get_clean();
+
+ // Markup should contain the one-line script and not the advanced script.
+ self::assertStringContainsString( self::$one_line_script_string, $output );
+ self::assertStringNotContainsString( self::$advanced_script_string, $output );
+ }
+
+ /**
+ * Verifies that the HTML markup is correctly output when using the one-line
+ * script with an additional option.
+ *
+ * @since 3.21.0
+ *
+ * @covers \Parsely\Headline_Testing::can_enable_feature
+ * @covers \Parsely\Headline_Testing::enqueue_headline_testing_script
+ * @covers \Parsely\Headline_Testing::enqueue_one_line_script
+ * @covers \Parsely\Headline_Testing::add_data_attributes_to_script_tag
+ * @covers \Parsely\Headline_Testing::run
+ * @uses \Parsely\Headline_Testing::__construct
+ * @uses \Parsely\Parsely::__construct
+ * @uses \Parsely\Parsely::allow_parsely_remote_requests
+ * @uses \Parsely\Parsely::are_credentials_managed
+ * @uses \Parsely\Parsely::get_default_options
+ * @uses \Parsely\Parsely::get_managed_credentials
+ * @uses \Parsely\Parsely::get_options
+ * @uses \Parsely\Parsely::get_site_id
+ * @uses \Parsely\Parsely::set_default_content_helper_settings_values
+ * @uses \Parsely\Parsely::set_default_full_metadata_in_non_posts
+ * @uses \Parsely\Parsely::set_managed_options
+ * @uses \Parsely\Parsely::site_id_is_set
+ * @uses \Parsely\Permissions::build_pch_permissions_settings_array
+ * @uses \Parsely\Permissions::get_user_roles_with_edit_posts_cap
+ * @uses \Parsely\Services\Content_API\Content_API_Service::get_base_url
+ * @uses \Parsely\Services\Suggestions_API\Suggestions_API_Service::get_base_url
+ */
+ public function test_markup_with_one_line_script_and_additional_option(): void {
+ $this->enable_headline_testing( array( 'enable_live_updates' => true ) );
+
+ self::$headline_testing->run();
+
+ // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
+ do_action( 'wp_enqueue_scripts' );
+
+ ob_start();
+ wp_print_scripts();
+ $output = (string) ob_get_clean();
+
+ // Markup should contain the one-line script with the option's attribute.
+ self::assertStringContainsString(
+ self::$one_line_script_string . ' data-enable-live-updates="true"',
+ $output
+ );
+ self::assertStringNotContainsString( self::$advanced_script_string, $output );
+ }
+
+ /**
+ * Verifies that running multiple wp_enqueue_scripts actions doesn't add
+ * duplicate attributes to the script markup.
+ *
+ * @since 3.21.0
+ *
+ * @covers \Parsely\Headline_Testing::can_enable_feature
+ * @covers \Parsely\Headline_Testing::enqueue_headline_testing_script
+ * @covers \Parsely\Headline_Testing::enqueue_one_line_script
+ * @covers \Parsely\Headline_Testing::add_data_attributes_to_script_tag
+ * @covers \Parsely\Headline_Testing::run
+ * @uses \Parsely\Headline_Testing::__construct
+ * @uses \Parsely\Parsely::__construct
+ * @uses \Parsely\Parsely::allow_parsely_remote_requests
+ * @uses \Parsely\Parsely::are_credentials_managed
+ * @uses \Parsely\Parsely::get_default_options
+ * @uses \Parsely\Parsely::get_managed_credentials
+ * @uses \Parsely\Parsely::get_options
+ * @uses \Parsely\Parsely::get_site_id
+ * @uses \Parsely\Parsely::set_default_content_helper_settings_values
+ * @uses \Parsely\Parsely::set_default_full_metadata_in_non_posts
+ * @uses \Parsely\Parsely::set_managed_options
+ * @uses \Parsely\Parsely::site_id_is_set
+ * @uses \Parsely\Permissions::build_pch_permissions_settings_array
+ * @uses \Parsely\Permissions::get_user_roles_with_edit_posts_cap
+ * @uses \Parsely\Services\Content_API\Content_API_Service::get_base_url
+ * @uses \Parsely\Services\Suggestions_API\Suggestions_API_Service::get_base_url
+ */
+ public function test_firing_multiple_wp_enqueue_scripts_does_not_duplicate_attributes(): void {
+ $this->enable_headline_testing( array( 'enable_live_updates' => true ) );
+
+ self::$headline_testing->run();
+
+ // Trigger wp_enqueue_scripts multiple times.
+ // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
+ do_action( 'wp_enqueue_scripts' );
+ // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
+ do_action( 'wp_enqueue_scripts' );
+ // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
+ do_action( 'wp_enqueue_scripts' );
+
+ ob_start();
+ wp_print_scripts();
+ $output = (string) ob_get_clean();
+
+ // Markup should contain the option's attribute only once.
+ self::assertStringContainsString( 'data-enable-live-updates="true"', $output );
+ $count = substr_count( $output, 'data-enable-live-updates="true"' );
+ self::assertSame(
+ 1,
+ $count,
+ 'The data-enable-live-updates attribute should appear exactly once, but appeared ' . $count . ' times.'
+ );
+ }
+
+ /**
+ * Verifies that the HTML markup is correctly output when using the advanced
+ * script.
+ *
+ * @since 3.21.0
+ *
+ * @covers \Parsely\Headline_Testing::can_enable_feature
+ * @covers \Parsely\Headline_Testing::enqueue_headline_testing_script
+ * @covers \Parsely\Headline_Testing::enqueue_advanced_script
+ * @covers \Parsely\Headline_Testing::run
+ * @uses \Parsely\Headline_Testing::__construct
+ * @uses \Parsely\Parsely::__construct
+ * @uses \Parsely\Parsely::allow_parsely_remote_requests
+ * @uses \Parsely\Parsely::are_credentials_managed
+ * @uses \Parsely\Parsely::get_default_options
+ * @uses \Parsely\Parsely::get_managed_credentials
+ * @uses \Parsely\Parsely::get_options
+ * @uses \Parsely\Parsely::get_site_id
+ * @uses \Parsely\Parsely::set_default_content_helper_settings_values
+ * @uses \Parsely\Parsely::set_default_full_metadata_in_non_posts
+ * @uses \Parsely\Parsely::set_managed_options
+ * @uses \Parsely\Parsely::site_id_is_set
+ * @uses \Parsely\Permissions::build_pch_permissions_settings_array
+ * @uses \Parsely\Permissions::get_user_roles_with_edit_posts_cap
+ * @uses \Parsely\Services\Content_API\Content_API_Service::get_base_url
+ * @uses \Parsely\Services\Suggestions_API\Suggestions_API_Service::get_base_url
+ */
+ public function test_markup_with_advanced_script_installation_method(): void {
+ $this->enable_headline_testing( array( 'installation_method' => 'advanced' ) );
+
+ self::$headline_testing->run();
+
+ // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
+ do_action( 'wp_enqueue_scripts' );
+
+ ob_start();
+ wp_print_scripts();
+ $output = (string) ob_get_clean();
+
+ // Markup should contain the advanced script and not the one-line script.
+ self::assertStringContainsString( self::$advanced_script_string, $output );
+ self::assertStringNotContainsString( self::$one_line_script_string, $output );
+ }
+
+ /**
+ * Verifies that the HTML markup is correctly output when using the advanced
+ * script with an additional option.
+ *
+ * @since 3.21.0
+ *
+ * @covers \Parsely\Headline_Testing::can_enable_feature
+ * @covers \Parsely\Headline_Testing::enqueue_headline_testing_script
+ * @covers \Parsely\Headline_Testing::enqueue_advanced_script
+ * @covers \Parsely\Headline_Testing::run
+ * @uses \Parsely\Headline_Testing::__construct
+ * @uses \Parsely\Parsely::__construct
+ * @uses \Parsely\Parsely::allow_parsely_remote_requests
+ * @uses \Parsely\Parsely::are_credentials_managed
+ * @uses \Parsely\Parsely::get_default_options
+ * @uses \Parsely\Parsely::get_managed_credentials
+ * @uses \Parsely\Parsely::get_options
+ * @uses \Parsely\Parsely::get_site_id
+ * @uses \Parsely\Parsely::set_default_content_helper_settings_values
+ * @uses \Parsely\Parsely::set_default_full_metadata_in_non_posts
+ * @uses \Parsely\Parsely::set_managed_options
+ * @uses \Parsely\Parsely::site_id_is_set
+ * @uses \Parsely\Permissions::build_pch_permissions_settings_array
+ * @uses \Parsely\Permissions::get_user_roles_with_edit_posts_cap
+ * @uses \Parsely\Services\Content_API\Content_API_Service::get_base_url
+ * @uses \Parsely\Services\Suggestions_API\Suggestions_API_Service::get_base_url
+ */
+ public function test_markup_with_advanced_script_and_additional_option(): void {
+ $this->enable_headline_testing(
+ array(
+ 'installation_method' => 'advanced',
+ 'enable_live_updates' => true,
+ )
+ );
+
+ self::$headline_testing->run();
+
+ // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
+ do_action( 'wp_enqueue_scripts' );
+
+ ob_start();
+ wp_print_scripts();
+ $output = (string) ob_get_clean();
+
+ // Markup should contain the advanced script with the option's config.
+ self::assertStringContainsString( self::$advanced_script_string, $output );
+ self::assertStringContainsString( 'enableLiveUpdates: true', $output );
+ self::assertStringNotContainsString( self::$one_line_script_string, $output );
+ }
+
+ /**
+ * Adds Headline Testing feature options, setting the feature to enabled and
+ * respecting any passed setting values.
+ *
+ * Default values will be used for any unspecified settings.
+ *
+ * @since 3.21.0
+ *
+ * @param array $settings Custom settings to save.
+ */
+ private function enable_headline_testing( array $settings = array() ): void {
+ TestCase::set_options(
+ array(
+ 'headline_testing' => array_merge(
+ ( new Parsely() )->get_default_options()['headline_testing'],
+ array( 'enabled' => true ),
+ $settings
+ ),
+ )
+ );
+ }
+}
diff --git a/tests/e2e/specs/activation-flow.spec.ts b/tests/e2e/specs/activation-flow.spec.ts
index 789cde3f7d..c38e5dde29 100644
--- a/tests/e2e/specs/activation-flow.spec.ts
+++ b/tests/e2e/specs/activation-flow.spec.ts
@@ -59,6 +59,8 @@ test.describe( 'Activation flow', (): void => {
const basicSection = page.locator( '.basic-section' );
const contentHelperTab = page.getByRole( 'link', { name: 'Content Intelligence' } );
const contentHelperSection = page.locator( '.content-intelligence-section' );
+ const headlineTestingTab = page.getByRole( 'link', { name: 'Headline Testing' } );
+ const headlineTestingSection = page.locator( '.headline-testing-section' );
const recrawlTab = page.getByRole( 'link', { name: 'Recrawl' } );
const recrawlSection = page.locator( '.recrawl-section' );
const advancedTab = page.getByRole( 'link', { name: 'Advanced' } );
@@ -67,41 +69,55 @@ test.describe( 'Activation flow', (): void => {
// Check that all tabs are present in the Settings page.
await expect( basicTab ).toBeVisible();
await expect( contentHelperTab ).toBeVisible();
+ await expect( headlineTestingTab ).toBeVisible();
await expect( recrawlTab ).toBeVisible();
await expect( advancedTab ).toBeVisible();
// Check that by default, the Basic Settings section is active.
await expect( basicSection ).toBeVisible();
await expect( contentHelperSection ).toBeHidden();
+ await expect( headlineTestingSection ).toBeHidden();
await expect( recrawlSection ).toBeHidden();
await expect( advancedSection ).toBeHidden();
- // Test section visibility when the Basic tab is clicked.
- await basicSection.click();
- await expect( basicSection ).toBeVisible();
- await expect( contentHelperSection ).toBeHidden();
+ // Test section visibility when the Content Intelligence tab is clicked.
+ await contentHelperTab.click();
+ await contentHelperSection.waitFor( { state: 'visible' } );
+ await expect( basicSection ).toBeHidden();
+ await expect( headlineTestingSection ).toBeHidden();
await expect( recrawlSection ).toBeHidden();
await expect( advancedSection ).toBeHidden();
- // Test section visibility when the Content Intelligence tab is clicked.
- await contentHelperTab.click();
+ // Test section visibility when the Headline Testing tab is clicked.
+ await headlineTestingTab.click();
+ await headlineTestingSection.waitFor( { state: 'visible' } );
await expect( basicSection ).toBeHidden();
- await expect( contentHelperSection ).toBeVisible();
+ await expect( contentHelperSection ).toBeHidden();
await expect( recrawlSection ).toBeHidden();
await expect( advancedSection ).toBeHidden();
// Test section visibility when the Recrawl tab is clicked.
await recrawlTab.click();
+ await recrawlSection.waitFor( { state: 'visible' } );
await expect( basicSection ).toBeHidden();
await expect( contentHelperSection ).toBeHidden();
- await expect( recrawlSection ).toBeVisible();
+ await expect( headlineTestingSection ).toBeHidden();
await expect( advancedSection ).toBeHidden();
// Test section visibility when the Advanced tab is clicked.
await advancedTab.click();
+ await advancedSection.waitFor( { state: 'visible' } );
await expect( basicSection ).toBeHidden();
await expect( contentHelperSection ).toBeHidden();
+ await expect( headlineTestingSection ).toBeHidden();
await expect( recrawlSection ).toBeHidden();
- await expect( advancedSection ).toBeVisible();
+
+ // Test section visibility when the Basic tab is clicked.
+ await basicTab.click();
+ await basicSection.waitFor( { state: 'visible' } );
+ await expect( contentHelperSection ).toBeHidden();
+ await expect( headlineTestingSection ).toBeHidden();
+ await expect( recrawlSection ).toBeHidden();
+ await expect( advancedSection ).toBeHidden();
} );
} );
diff --git a/wp-parsely.php b/wp-parsely.php
index 7487e97768..0080e7a63c 100644
--- a/wp-parsely.php
+++ b/wp-parsely.php
@@ -31,6 +31,7 @@
use Parsely\Content_Helper\Post_List_Stats;
use Parsely\Endpoints\GraphQL_Metadata;
use Parsely\Endpoints\Rest_Metadata;
+use Parsely\Headline_Testing;
use Parsely\Integrations\Amp;
use Parsely\Integrations\Google_Web_Stories;
use Parsely\Integrations\Integrations;
@@ -97,6 +98,10 @@ function parsely_initialize_plugin(): void {
$metadata_renderer = new Metadata_Renderer( $parsely );
$metadata_renderer->run();
+
+ // Initialize Headline Testing feature.
+ $headline_testing = new Headline_Testing( $parsely );
+ $headline_testing->run();
}
add_action( 'admin_init', __NAMESPACE__ . '\\parsely_admin_init_register' );