This commit is contained in:
2026-07-02 15:54:39 -06:00
commit 9883323161
17470 changed files with 4470592 additions and 0 deletions
@@ -0,0 +1,38 @@
<?php
/**
* Autoload Core Classes.
*
* Custom autoloader for WordPress file and class names standards.
*
* Few examples
* Vrts\Core\Plugin - includes/core/class-plugin-php
* Vrts\Core\Utilities\Assets - includes/core/utilities/class-assets-php
*/
spl_autoload_register( function ( $class_name ) {
$namespace = 'Vrts\\';
if ( strpos( $class_name, $namespace ) !== 0 ) {
return false;
}
$path = plugin_dir_path( VRTS_PLUGIN_FILE ) . 'includes';
$parts = explode( '\\', substr( $class_name, strlen( $namespace ) ) );
$count_parts = count( $parts );
foreach ( $parts as $key => $part ) {
$part = str_replace( '_', '-', strtolower( $part ) );
$prefix = ( $key + 1 === $count_parts ) ? '/class-' : '/';
$path .= $prefix . $part;
}
$path .= '.php';
if ( ! file_exists( $path ) ) {
return false;
}
require_once $path;
return true;
});
@@ -0,0 +1,388 @@
<?php
namespace Vrts\Core;
use Exception;
use WP_Filesystem_Direct;
use Vrts\Core\Settings\Manager;
use Vrts\Core\Traits\Singleton;
use Vrts\Core\Traits\Macroable;
/**
* Main class responsible for defining all plugin functionalities.
*
* It has methods that are definied in other classes and made available here using the Macroable.
*
* @method string thisMenuHasMacro() Added in Vrts\Features\Menu
*/
class Plugin {
use Singleton;
use Macroable;
/**
* Plugin file.
*
* @var string
*/
protected $plugin_file;
/**
* Plugin identifier.
*
* @var string
*/
protected $plugin_identifier;
/**
* Modules and objects instances list
*
* @var array
*/
protected $factory = [];
/**
* Load plugin functions and features.
*
* @param string $plugin_identifier Plugin identifier.
* @param array $features Plugin features.
*/
public function setup( $plugin_identifier, $features ) {
$this->plugin_identifier = $plugin_identifier;
$this->plugin_file = VRTS_PLUGIN_FILE;
$this->features( $features );
}
/**
* Get Plugin identifier.
*
* @return string Plugin identifier.
*/
public function get_plugin_identifier() {
return str_replace( '-', '_', $this->plugin_identifier );
}
/**
* Get the plugin url.
*
* @param string $file File to return the url for in the plugin directory.
*
* @return string
*/
public function get_plugin_url( $file = '' ) {
$file = ltrim( $file, '/' );
if ( empty( $file ) ) {
$url = plugin_dir_url( $this->plugin_file );
} else {
$url = plugin_dir_url( $this->plugin_file ) . $file;
}
return $url;
}
/**
* Get the plugin path.
*
* @param string $file File to return the path for in the plugin directory.
*
* @return string
*/
public function get_plugin_path( $file = '' ) {
$file = ltrim( $file, '/' );
if ( empty( $file ) ) {
$path = plugin_dir_path( $this->plugin_file );
} else {
$path = plugin_dir_path( $this->plugin_file ) . $file;
}
return $path;
}
/**
* Get the plugin file.
*
* @return string
*/
public function get_plugin_file() {
return $this->plugin_file;
}
/**
* Auto include files from defined directories.
*
* @param array $directories Directories to auto include files from.
*/
protected function includes( $directories ) {
foreach ( $directories as $directory ) {
foreach ( glob( $this->get_plugin_path( "{$directory}/*.php" ) ) as $filename ) {
require_once $filename;
}
}
}
/**
* Load plugin features.
*
* @param array $features Plugin features.
*/
protected function features( $features ) {
foreach ( $features as $namespace => $directory ) {
foreach ( glob( $this->get_plugin_path( "{$directory}/class-*.php" ) ) as $filename ) {
$dir = $this->get_plugin_path( $directory );
$filename = str_replace( $dir, '', $filename );
$class_name = str_replace( [ '/class-', '.php' ], '', $filename );
$class_name = ucwords( $class_name, '-' );
$class_name = str_replace( '-', '_', $class_name );
$class_name = $namespace . $class_name;
new $class_name();
}
}
}
/**
* Create objects.
*
* @param string $key Array key that the object will be accessible.
* @param object $instance object that needs to created.
*
* @return object Created object.
*/
protected function factory( $key, $instance ) {
if ( ! isset( $this->factory[ $key ] ) ) {
$this->factory[ $key ] = $instance;
}
return $this->factory[ $key ];
}
/**
* Get Settings Manager.
*
* @return Manager Settings Manager.
*/
public function settings() {
return $this->factory( 'settings', new Manager() );
}
/**
* Get the plugin informations.
*
* @param string $info What information do we want.
*
* @return string Desired plugin information.
*/
public function get_plugin_info( $info ) {
if ( ! function_exists( 'get_plugin_data' ) ) {
require_once ABSPATH . 'wp-admin/includes/plugin.php';
}
$plugin = get_plugin_data( $this->plugin_file );
$infos = [
'name' => 'Name',
'version' => 'Version',
'uri' => 'PluginURI',
'author' => 'Author',
'author_uri' => 'AuthorURI',
'description' => 'Description',
'requires_wp' => 'RequiresWP',
'requires_php' => 'RequiresPHP',
'text_domain' => 'TextDomain',
'domain_path' => 'DomainPath',
];
return isset( $infos[ $info ] ) ? $plugin[ $infos[ $info ] ] : '';
}
/**
* Get a component with passing arguments.
*
* Makes it easy for a plugin to reuse sections of code.
*
* @param string $name The component name.
* @param array $data Pass data with the component load.
*
* @return string Component markup
*
* @throws Exception If there is no file found.
*/
public function get_component( $name, $data = [] ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- $data is used by included template files.
ob_start();
if ( file_exists( $this->get_plugin_path( "components/{$name}/index.php" ) ) ) {
include $this->get_plugin_path( "components/{$name}/index.php" );
} else {
throw new Exception( esc_html( "Components file 'components/{$name}/index.php' cannot be located" ) );
}
return ob_get_clean();
}
/**
* Load a component with passing arguments.
*
* Makes it easy for a plugin to reuse sections of code.
*
* @param string $name The component name.
* @param array $data Pass data with the component load.
*
* @throws Exception If there is no file found.
*/
public function component( $name, $data = [] ) {
$safe_escaped_component = $this->get_component( $name, $data );
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- It's escaped.
echo $safe_escaped_component;
}
/**
* Get an icon.
*
* @param string $icon The icon name.
* @param bool $escape If true it will escape the icon.
*
* @return string Icon.
*/
public function get_icon( $icon, $escape = true ) {
require_once ABSPATH . 'wp-admin/includes/class-wp-filesystem-base.php';
require_once ABSPATH . 'wp-admin/includes/class-wp-filesystem-direct.php';
$filesystem = new WP_Filesystem_Direct( null );
$icon = $filesystem->get_contents( $this->get_plugin_path( "assets/icons/{$icon}.svg" ) );
if ( $escape ) {
return wp_kses( $icon, $this->wp_kses_svg() );
}
return $icon;
}
/**
* Load an icon.
*
* @param string $icon The icon name.
*/
public function icon( $icon ) {
echo wp_kses(
$this->get_icon( $icon, false ),
$this->wp_kses_svg()
);
}
/**
* Get the plugin logo.
*
* @param bool $escape If true it will escape the logog.
*
* @return string the logo as string.
*/
public function get_logo( $escape = true ) {
require_once ABSPATH . 'wp-admin/includes/class-wp-filesystem-base.php';
require_once ABSPATH . 'wp-admin/includes/class-wp-filesystem-direct.php';
$filesystem = new WP_Filesystem_Direct( null );
$logo = $filesystem->get_contents( $this->get_plugin_path( 'assets/images/vrts-logo.svg' ) );
if ( $escape ) {
return wp_kses( $logo, $this->wp_kses_svg() );
}
return $logo;
}
/**
* The plugin logo.
*/
public function logo() {
echo wp_kses(
$this->get_logo( false ),
$this->wp_kses_svg()
);
}
/**
* Get public post types.
*
* @return array public post types.
*/
public function get_public_post_types() {
$default_post_types = [ 'post', 'page' ];
$custom_post_types = get_post_types( [
'public' => true,
'_builtin' => false,
] );
return array_merge( $default_post_types, $custom_post_types );
}
/**
* Get snapshot placeholder image.
*
* @param boolean $base64 return base 64 encoded or not.
*
* @return string the placeholder image as string.
*/
public function get_snapshot_placeholder_image( $base64 = true ) {
$icon_path = $this->get_plugin_path( 'assets/images/vrts-snapshot-placeholder.svg' );
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- It's a file.
$svg = file_get_contents( $icon_path );
if ( $base64 ) {
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- It's a file.
return 'data:image/svg+xml;base64,' . base64_encode( $svg );
}
return $svg;
}
/**
* Get allowed SVG tags and attributes.
*
* @return array Allowed SVG tags and attributes.
*/
private function wp_kses_svg() {
return apply_filters( 'vrts_wp_kses_svg', [
'svg' => [
'class' => true,
'width' => true,
'height' => true,
'version' => true,
'fill' => true,
'viewbox' => true,
'xmlns' => true,
'aria-hidden' => true,
'focusable' => true,
'style' => [],
'xml:space' => [],
],
'path' => [
'd' => true,
'fill' => true,
'stroke' => true,
'stroke-linecap' => true,
'stroke-linejoin' => true,
'stroke-width' => true,
'vector-effect' => true,
'transform-origin' => true,
],
'circle' => [
'fill' => true,
'stroke' => true,
'cx' => true,
'cy' => true,
'r' => true,
'vector-effect' => true,
],
'rect' => [
'x' => true,
'y' => true,
'width' => true,
'height' => true,
'fill' => true,
'class' => true,
'rx' => true,
],
] );
}
}
@@ -0,0 +1,138 @@
<?php
namespace Vrts\Core\Settings;
class Manager {
/**
* Sections
*
* @var array
*/
protected $sections = [];
/**
* Settings.
*
* @var array
*/
protected $settings = [];
/**
* Constructor.
*/
public function __construct() {
add_action( 'admin_init', [ $this, 'register_settings' ] );
}
/**
* Register settings.
*/
public function register_settings() {
$this->add_sections();
$this->add_settings();
}
/**
* Get option.
*
* @param string $id Settings setting ID.
* @param bool $transform_value Transform value.
*
* @return mixed Option value.
*/
public function get_option( $id, $transform_value = true ) {
$default_value = isset( $this->settings[ $id ] ) ? $this->settings[ $id ]['default'] ?? '' : false;
$value = get_option( $id, $default_value );
if ( $transform_value && isset( $this->settings[ $id ]['return_value_callback'] ) ) {
$value = call_user_func( $this->settings[ $id ]['return_value_callback'], $value );
}
return $value;
}
/**
* Get settings section page.
*
* @param string $id Setting section ID.
*
* @return string
*/
private function get_section_page( $id ) {
return isset( $this->sections[ $id ] ) ? $this->sections[ $id ]['page'] : false;
}
/**
* Output field.
*
* @param array $args Setting args.
*/
private function get_field( $args ) {
$value = $args['value'] ?? $this->get_option( $args['id'], false );
include vrts()->get_plugin_path( "includes/core/settings/field-{$args['type']}/index.php" );
}
/**
* Add all sections.
*/
private function add_sections() {
foreach ( $this->sections as $id => $args ) {
add_settings_section(
$id,
$args['title'] ?? '',
$args['callback'] ?? '__return_false',
$args['page'] ?? 'general'
);
}
}
/**
* Add all settings.
*/
private function add_settings() {
foreach ( $this->settings as $id => $args ) {
register_setting(
$this->get_section_page( $args['section'] ),
$id,
[
'type' => $args['value_type'] ?? 'string',
'sanitize_callback' => $args['sanitize_callback'] ?? null,
'show_in_rest' => $args['show_in_rest'] ?? false,
'default' => $args['default'] ?? '',
]
);
add_settings_field(
$id,
$args['title'] ?? '',
function () use ( $args ) {
$this->get_field( $args );
},
$this->get_section_page( $args['section'] ),
$args['section'],
[
'class' => "vrts-settings-{$args['type']}",
'label_for' => ! in_array( $args['type'], [ 'info', 'checkbox', 'radio' ], true ) ? $id : '',
]
);
}//end foreach
}
/**
* Add a settings section.
*
* @param array $args Array of properties for the new section object.
*/
public function add_section( $args = [] ) {
$this->sections[ $args['id'] ] = $args;
}
/**
* Add a settings setting.
*
* @param array $args Array of properties for the new setting and control object.
*/
public function add_setting( $args = [] ) {
$this->settings[ $args['id'] ] = $args;
}
}
@@ -0,0 +1,28 @@
<fieldset <?php echo isset( $args['is_pro'] ) && false === $args['is_pro'] ? 'data-a11y-dialog-show="vrts-modal-pro-settings"' : ''; ?>>
<legend class="screen-reader-text"><?php echo esc_html( $args['title'] ); ?></legend>
<label>
<input
type="checkbox"
id="<?php echo esc_attr( $args['id'] ); ?>"
name="<?php echo esc_html( $args['id'] ); ?>"
value="1"
<?php checked( $value, 1 ); ?>
<?php wp_readonly( isset( $args['readonly'] ) && $args['readonly'] ); ?>
<?php disabled( isset( $args['disabled'] ) && $args['disabled'] ); ?>>
<?php echo wp_kses_post( $args['label'] ); ?>
</label>
<?php
if ( isset( $args['is_pro'] ) && false === $args['is_pro'] ) :
?>
<span class="vrts-settings__pro-label"><?php esc_html_e( 'Pro', 'visual-regression-tests' ); ?></span>
<?php
endif;
?>
</fieldset>
<?php
if ( isset( $args['description'] ) ) :
?>
<p class="description"><?php echo wp_kses_post( $args['description'] ); ?></p>
<?php
endif;
@@ -0,0 +1,10 @@
<fieldset>
<?php echo wp_kses_post( $args['description'] ); ?>
<?php
if ( isset( $args['is_pro'] ) && false === $args['is_pro'] ) :
?>
<span class="vrts-settings__pro-label"><?php esc_html_e( 'Pro', 'visual-regression-tests' ); ?></span>
<?php
endif;
?>
</fieldset>
@@ -0,0 +1,23 @@
<fieldset <?php echo isset( $args['is_pro'] ) && false === $args['is_pro'] ? 'data-a11y-dialog-show="vrts-modal-pro-settings"' : ''; ?>>
<input
type="password" id="<?php echo esc_attr( $args['id'] ); ?>"
class="regular-text"
name="<?php echo esc_attr( $args['id'] ); ?>"
value="<?php echo esc_attr( $value ); ?>"
placeholder="<?php echo esc_attr( $args['placeholder'] ); ?>"
<?php wp_readonly( isset( $args['readonly'] ) && $args['readonly'] ); ?>
<?php disabled( isset( $args['disabled'] ) && $args['disabled'] ); ?>>
<?php
if ( isset( $args['is_pro'] ) && false === $args['is_pro'] ) :
?>
<span class="vrts-settings__pro-label"><?php esc_html_e( 'Pro', 'visual-regression-tests' ); ?></span>
<?php
endif;
?>
</fieldset>
<?php
if ( isset( $args['description'] ) ) :
?>
<p class="description"><?php echo wp_kses_post( $args['description'] ); ?></p>
<?php
endif;
@@ -0,0 +1,26 @@
<fieldset <?php echo isset( $args['is_pro'] ) && false === $args['is_pro'] ? 'data-a11y-dialog-show="vrts-modal-pro-settings"' : ''; ?>>
<select
name="<?php echo esc_attr( $args['id'] ); ?>"
id="<?php echo esc_attr( $args['id'] ); ?>"
<?php wp_readonly( isset( $args['readonly'] ) && $args['readonly'] ); ?>
<?php disabled( isset( $args['disabled'] ) && $args['disabled'] ); ?>>
<?php
foreach ( $args['choices'] as $key => $name ) :
?>
<option value="<?php echo esc_attr( $key ); ?>" <?php selected( $key, $value ); ?>><?php echo esc_html( $name ); ?></option>
<?php endforeach; ?>
</select>
<?php
if ( isset( $args['is_pro'] ) && false === $args['is_pro'] ) :
?>
<span class="vrts-settings__pro-label"><?php esc_html_e( 'Pro', 'visual-regression-tests' ); ?></span>
<?php
endif;
?>
</fieldset>
<?php
if ( isset( $args['description'] ) ) :
?>
<p class="description"><?php echo wp_kses_post( $args['description'] ); ?></p>
<?php
endif;
@@ -0,0 +1,23 @@
<fieldset <?php echo isset( $args['is_pro'] ) && false === $args['is_pro'] ? 'data-a11y-dialog-show="vrts-modal-pro-settings"' : ''; ?>>
<input
type="text" id="<?php echo esc_attr( $args['id'] ); ?>"
class="regular-text"
name="<?php echo esc_attr( $args['id'] ); ?>"
value="<?php echo esc_attr( $value ); ?>"
placeholder="<?php echo esc_attr( $args['placeholder'] ); ?>"
<?php wp_readonly( isset( $args['readonly'] ) && $args['readonly'] ); ?>
<?php disabled( isset( $args['disabled'] ) && $args['disabled'] ); ?>>
<?php
if ( isset( $args['is_pro'] ) && false === $args['is_pro'] ) :
?>
<span class="vrts-settings__pro-label"><?php esc_html_e( 'Pro', 'visual-regression-tests' ); ?></span>
<?php
endif;
?>
</fieldset>
<?php
if ( isset( $args['description'] ) ) :
?>
<p class="description"><?php echo wp_kses_post( $args['description'] ); ?></p>
<?php
endif;
@@ -0,0 +1,103 @@
<?php
namespace Vrts\Core\Traits;
use Closure;
use ReflectionClass;
use ReflectionMethod;
use BadMethodCallException;
trait Macroable {
/**
* Macros.
*
* @var array
*/
protected static $macros = [];
/**
* Registerd macro.
*
* @param string $name Name of macro function.
* @param callable $macro Callback function.
*/
public static function macro( $name, $macro ) {
static::$macros[ $name ] = $macro;
}
/**
* Register mixins.
*
* @param class $mixin Class with mixins.
*/
public static function mixin( $mixin ) {
$methods = ( new ReflectionClass( $mixin ) )->getMethods(
ReflectionMethod::IS_PUBLIC | ReflectionMethod::IS_PROTECTED
);
foreach ( $methods as $method ) {
$method->setAccessible( true );
static::macro( $method->name, $method->invoke( $mixin ) );
}
}
/**
* Return true if has macro.
*
* @param string $name Name of macro function.
*
* @return bool
*/
public static function has_macro( $name ) {
return isset( static::$macros[ $name ] );
}
/**
* Call static method.
*
* @param string $method The function to be called.
* @param array $parameters The parameters to be passed to the function, as an indexed array.
* @return callable The function to be called.
*
* @throws BadMethodCallException When method doesn't exist.
*/
public static function __callStatic( $method, $parameters ) {
if ( ! static::has_macro( $method ) ) {
throw new BadMethodCallException( esc_html( "Method {$method} does not exist." ) );
}
$macro = static::$macros[ $method ];
if ( $macro instanceof Closure ) {
return call_user_func_array( Closure::bind( $macro, null, static::class ), $parameters );
}
return call_user_func_array( $macro, $parameters );
}
/**
* Call method.
*
* @param string $method The function to be called.
* @param array $parameters The parameters to be passed to the function, as an indexed array.
* @return callable The function to be called.
*
* @throws BadMethodCallException When method doesn't exist.
*/
public function __call( $method, $parameters ) {
if ( ! static::has_macro( $method ) ) {
throw new BadMethodCallException( esc_html( "Method {$method} does not exist." ) );
}
$macro = static::$macros[ $method ];
if ( $macro instanceof Closure ) {
return call_user_func_array( $macro->bindTo( $this, static::class ), $parameters );
}
return call_user_func_array( $macro, $parameters );
}
}
@@ -0,0 +1,43 @@
<?php
namespace Vrts\Core\Traits;
trait Singleton {
/**
* The reference to Singleton instance of this class.
*
* @var Singleton
*/
protected static $instance;
/**
* Returns the Singleton instance of this class.
*
* @return Singleton The Singleton instance.
*/
public static function get_instance() {
if ( is_null( self::$instance ) ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Protected constructor to prevent creating a new instance of the
* Singleton via the `new` operator from outside of this class.
*/
protected function __construct() {}
/**
* Private clone method to prevent cloning of the instance of the
* Singleton instance.
*/
private function __clone() {}
/**
* Public unserialize method to prevent unserializing of the Singleton
* instance.
*/
public function __wakeup() {}
}
@@ -0,0 +1,121 @@
<?php
namespace Vrts\Core\Utilities;
class Array_Helpers {
/**
* Parses the string into variables without the max_input_vars limitation.
*
* @param string $str String.
*
* @return array Parsed array.
*/
public static function parse_str( $str ) {
if ( '' === $str ) {
return false;
}
$result = [];
$pairs = explode( '&', $str );
foreach ( $pairs as $key => $pair ) {
// use the original parse_str() on each element.
parse_str( $pair, $params );
$k = key( $params );
if ( ! isset( $result[ $k ] ) ) {
$result += $params;
} else {
$result[ $k ] = self::array_merge_recursive( $result[ $k ], $params[ $k ] );
}
}
return $result;
}
/**
* Merge arrays without converting values with duplicate keys to arrays as array_merge_recursive does.
*
* As seen here http://php.net/manual/en/function.array-merge-recursive.php#92195
*
* @param array $array1 First array.
* @param array $array2 Second array.
*
* @return array Merged array.
*/
public static function array_merge_recursive( array $array1, array $array2 ) {
$merged = $array1;
foreach ( $array2 as $key => $value ) {
if ( is_array( $value ) && isset( $merged[ $key ] ) && is_array( $merged[ $key ] ) ) {
$merged[ $key ] = self::array_merge_recursive( $merged[ $key ], $value );
} elseif ( is_numeric( $key ) && isset( $merged[ $key ] ) ) {
$merged[] = $value;
} else {
$merged[ $key ] = $value;
}
}
return $merged;
}
/**
* Recursive wp_parse_args for multidimensional arrays.
*
* @see http://mekshq.com/recursive-wp-parse-args-wordpress-function/.
*
* @param array $args Value to merge with $defaults.
* @param array $defaults Array that serves as the defaults.
*
* @return array Merged user defined values with defaults.
*/
public static function parse_args( $args, $defaults ) {
$args = (array) $args;
$defaults = (array) $defaults;
$result = $defaults;
foreach ( $args as $k => $v ) {
if ( is_array( $v ) && isset( $result[ $k ] ) ) {
$result[ $k ] = self::parse_args( $v, $result[ $k ] );
} else {
$result[ $k ] = $v;
}
}
return $result;
}
/**
* Implode array keys with desired value.
*
* @param array $arr Array to implode.
* @param string $value Checked for this value.
* @param string|array $remove Array key or keys to remove before implode.
* @param string $before Value to prepend to array keys.
* @param string $after Value to append to array keys.
*
* @return string Imploded array keys with value.
*/
public static function implode_array_keys( $arr, $value, $remove = false, $before = '', $after = '' ) {
if ( is_array( $remove ) ) {
foreach ( $remove as $key ) {
unset( $arr[ $key ] );
}
} elseif ( $remove ) {
unset( $arr[ $remove ] );
}
$new_array = [];
foreach ( $arr as $key => $item ) {
if ( $arr[ $key ] === $value ) {
$new_array[ $before . $key . $after ] = $item;
}
}
return implode( ' ', array_keys( $new_array ) );
}
}
@@ -0,0 +1,177 @@
<?php
namespace Vrts\Core\Utilities;
/**
* Async Request.
*
* @see https://github.com/deliciousbrains/wp-background-processing
*/
abstract class Async_Request {
/**
* Prefix.
*
* @var string
*/
protected $prefix;
/**
* Action.
*
* @var string
*/
protected $action = 'async_request';
/**
* Identifier.
*
* @var mixed
*/
protected $identifier;
/**
* Data.
*
* @var array
*/
protected $data = [];
/**
* Initiate new async request.
*/
public function __construct() {
// if the prefix hasn't been set explicity use the theme identifier.
if ( is_null( $this->prefix ) ) {
$this->prefix = vrts()->get_plugin_identifier();
}
// Uses unique prefix per blog so each blog has separate queue.
$this->prefix = $this->prefix . '_' . get_current_blog_id();
$this->identifier = $this->prefix . '_' . $this->action;
add_action( 'wp_ajax_' . $this->identifier, [ $this, 'maybe_handle' ] );
add_action( 'wp_ajax_nopriv_' . $this->identifier, [ $this, 'maybe_handle' ] );
}
/**
* Set data used during the request.
*
* @param array $data Data.
*
* @return $this
*/
public function data( $data ) {
$this->data = $data;
return $this;
}
/**
* Dispatch the async request.
*
* @return array|WP_Error
*/
public function dispatch() {
$url = add_query_arg( $this->get_query_args(), $this->get_query_url() );
$args = $this->get_post_args();
return wp_remote_post( esc_url_raw( $url ), $args );
}
/**
* Get query args.
*
* @return array
*/
protected function get_query_args() {
if ( property_exists( $this, 'query_args' ) ) {
return $this->query_args;
}
$args = [
'action' => $this->identifier,
'nonce' => wp_create_nonce( $this->identifier ),
];
/**
* Filters the post arguments used during an async request.
*
* @param array $url
*/
// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- Prefix is theme identifier and action name.
return apply_filters( $this->identifier . '_query_args', $args );
}
/**
* Get query URL.
*
* @return string
*/
protected function get_query_url() {
if ( property_exists( $this, 'query_url' ) ) {
return $this->query_url;
}
$url = admin_url( 'admin-ajax.php' );
/**
* Filters the post arguments used during an async request.
*
* @param string $url
*/
// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- Prefix is theme identifier and action name.
return apply_filters( $this->identifier . '_query_url', $url );
}
/**
* Get post args.
*
* @return array
*/
protected function get_post_args() {
if ( property_exists( $this, 'post_args' ) ) {
return $this->post_args;
}
$args = [
'timeout' => 0.01,
'blocking' => false,
'body' => $this->data,
'cookies' => $_COOKIE,
// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Using default wp hook.
'sslverify' => apply_filters( 'https_local_ssl_verify', false ),
];
/**
* Filters the post arguments used during an async request.
*
* @param array $args
*/
// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- Prefix is theme identifier and action name.
return apply_filters( $this->identifier . '_post_args', $args );
}
/**
* Maybe handle.
*
* Check for correct nonce and pass to handler.
*/
public function maybe_handle() {
// Don't lock up other requests while processing.
session_write_close();
check_ajax_referer( $this->identifier, 'nonce' );
$this->handle();
wp_die();
}
/**
* Handle.
*
* Override this method to perform any actions required
* during the async request.
*/
abstract protected function handle();
}
@@ -0,0 +1,480 @@
<?php
namespace Vrts\Core\Utilities;
/**
* Background Process.
*
* @see https://github.com/deliciousbrains/wp-background-processing
*/
abstract class Background_Process extends Async_Request {
/**
* Action name.
*
* @var string
*/
protected $action = 'background_process';
/**
* Start time of current process.
*
* @var int
*/
protected $start_time = 0;
/**
* Cron_hook_identifier.
*
* @var mixed
*/
protected $cron_hook_identifier;
/**
* Cron_interval_identifier.
*
* @var mixed
*/
protected $cron_interval_identifier;
/**
* Initiate new background process.
*/
public function __construct() {
parent::__construct();
$this->cron_hook_identifier = $this->identifier . '_cron';
$this->cron_interval_identifier = $this->identifier . '_cron_interval';
add_action( $this->cron_hook_identifier, [ $this, 'handle_cron_healthcheck' ] );
// phpcs:ignore WordPress.WP.CronInterval -- Verified 5 min.
add_filter( 'cron_schedules', [ $this, 'schedule_cron_healthcheck' ] );
}
/**
* Dispatch.
*/
public function dispatch() {
// Schedule the cron healthcheck.
$this->schedule_event();
// Perform remote post.
return parent::dispatch();
}
/**
* Push to queue.
*
* @param mixed $data Data.
*
* @return $this
*/
public function push_to_queue( $data ) {
$this->data[] = $data;
return $this;
}
/**
* Save queue.
*
* @return $this
*/
public function save() {
$key = $this->generate_key();
if ( ! empty( $this->data ) ) {
update_site_option( $key, $this->data );
}
return $this;
}
/**
* Update queue.
*
* @param string $key Key.
* @param array $data Data.
*
* @return $this
*/
public function update( $key, $data ) {
if ( ! empty( $data ) ) {
update_site_option( $key, $data );
}
return $this;
}
/**
* Delete queue.
*
* @param string $key Key.
*
* @return $this
*/
public function delete( $key ) {
delete_site_option( $key );
return $this;
}
/**
* Generates a unique key based on microtime. Queue items are
* given a unique key so that they can be merged upon save.
*
* @param int $length Length.
*
* @return string
*/
protected function generate_key( $length = 64 ) {
$unique = md5( microtime() . wp_rand() );
$prepend = $this->identifier . '_batch_';
return substr( $prepend . $unique, 0, $length );
}
/**
* Checks whether data exists within the queue and that
* the process is not already running.
*/
public function maybe_handle() {
// Don't lock up other requests while processing.
session_write_close();
if ( $this->is_process_running() ) {
// Background process already running.
wp_die();
}
if ( $this->is_queue_empty() ) {
// No data to process.
wp_die();
}
check_ajax_referer( $this->identifier, 'nonce' );
$this->handle();
wp_die();
}
/**
* Is queue empty.
*
* @return bool
*/
protected function is_queue_empty() {
global $wpdb;
$table = $wpdb->options;
$column = 'option_name';
if ( is_multisite() ) {
$table = $wpdb->sitemeta;
$column = 'meta_key';
}
$key = $wpdb->esc_like( $this->identifier . '_batch_' ) . '%';
// Interpolated variables $table and $column are OK to use like this.
// @codingStandardsIgnoreStart.
$count = $wpdb->get_var( $wpdb->prepare( "
SELECT COUNT(*)
FROM {$table}
WHERE {$column} LIKE %s
", $key ) );
// @codingStandardsIgnoreEnd.
return ( $count > 0 ) ? false : true;
}
/**
* Check whether the current process is already running
* in a background process.
*/
protected function is_process_running() {
if ( get_site_transient( $this->identifier . '_process_lock' ) ) {
// Process already running.
return true;
}
return false;
}
/**
* Lock the process so that multiple instances can't run simultaneously.
* Override if applicable, but the duration should be greater than that
* defined in the time_exceeded() method.
*/
protected function lock_process() {
// Set start time of current process.
$this->start_time = time();
// 1 minute
$lock_duration = ( property_exists( $this, 'queue_lock_time' ) ) ? $this->queue_lock_time : 60;
// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- Prefix is theme identifier and action name.
$lock_duration = apply_filters( $this->identifier . '_queue_lock_time', $lock_duration );
set_site_transient( $this->identifier . '_process_lock', microtime(), $lock_duration );
}
/**
* Unlock the process so that other instances can spawn.
*
* @return $this
*/
protected function unlock_process() {
delete_site_transient( $this->identifier . '_process_lock' );
return $this;
}
/**
* Get batch.
*
* @return stdClass Return the first batch from the queue
*/
protected function get_batch() {
global $wpdb;
$table = $wpdb->options;
$column = 'option_name';
$key_column = 'option_id';
$value_column = 'option_value';
if ( is_multisite() ) {
$table = $wpdb->sitemeta;
$column = 'meta_key';
$key_column = 'meta_id';
$value_column = 'meta_value';
}
$key = $wpdb->esc_like( $this->identifier . '_batch_' ) . '%';
// Interpolated variables $table, $column and $key_column are OK to use like this.
// @codingStandardsIgnoreStart.
$query = $wpdb->get_row( $wpdb->prepare( "
SELECT *
FROM {$table}
WHERE {$column} LIKE %s
ORDER BY {$key_column} ASC
LIMIT 1
", $key ) );
// @codingStandardsIgnoreEnd.
$batch = new \stdClass();
$batch->key = $query->$column;
$batch->data = maybe_unserialize( $query->$value_column );
return $batch;
}
/**
* Pass each queue item to the task handler, while remaining
* within server memory and time limit constraints.
*/
protected function handle() {
$this->lock_process();
do {
$batch = $this->get_batch();
foreach ( $batch->data as $key => $value ) {
$task = $this->task( $value );
if ( false !== $task ) {
$batch->data[ $key ] = $task;
} else {
unset( $batch->data[ $key ] );
}
if ( $this->time_exceeded() || $this->memory_exceeded() ) {
// Batch limits reached.
break;
}
}
// Update or delete current batch.
if ( ! empty( $batch->data ) ) {
$this->update( $batch->key, $batch->data );
} else {
$this->delete( $batch->key );
}
} while ( ! $this->time_exceeded() && ! $this->memory_exceeded() && ! $this->is_queue_empty() );
$this->unlock_process();
// Start next batch or complete process.
if ( ! $this->is_queue_empty() ) {
$this->dispatch();
} else {
$this->complete();
}
wp_die();
}
/**
* Ensures the batch process never exceeds 90%
* of the maximum WordPress memory.
*
* @return bool
*/
protected function memory_exceeded() {
$memory_limit = $this->get_memory_limit() * 0.9;
// 90% of max memory.
$current_memory = memory_get_usage( true );
$return = false;
if ( $current_memory >= $memory_limit ) {
$return = true;
}
// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- Prefix is theme identifier and action name.
return apply_filters( $this->identifier . '_memory_exceeded', $return );
}
/**
* Get memory limit.
*
* @return int
*/
protected function get_memory_limit() {
if ( function_exists( 'ini_get' ) ) {
$memory_limit = ini_get( 'memory_limit' );
} else {
// Sensible default.
$memory_limit = '128M';
}
if ( ! $memory_limit || - 1 === intval( $memory_limit ) ) {
// Unlimited, set to 32GB.
$memory_limit = '32000M';
}
return wp_convert_hr_to_bytes( $memory_limit );
}
/**
* Ensures the batch never exceeds a sensible time limit.
* A timeout limit of 30s is common on shared hosting.
*
* @return bool
*/
protected function time_exceeded() {
// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- Prefix is theme identifier and action name.
$finish = $this->start_time + apply_filters( $this->identifier . '_default_time_limit', 20 );
// 20 seconds.
$return = false;
if ( time() >= $finish ) {
$return = true;
}
// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- Prefix is theme identifier and action name.
return apply_filters( $this->identifier . '_time_exceeded', $return );
}
/**
* Override if applicable, but ensure that the below actions are
* performed, or, call parent::complete().
*/
protected function complete() {
// Unschedule the cron healthcheck.
$this->clear_scheduled_event();
}
/**
* Schedule cron healthcheck.
*
* @access public
*
* @param mixed $schedules Schedules.
*
* @return mixed
*/
public function schedule_cron_healthcheck( $schedules ) {
// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- Prefix is theme identifier and action name.
$interval = apply_filters( $this->identifier . '_cron_interval', 5 );
if ( property_exists( $this, 'cron_interval' ) ) {
// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- Prefix is theme identifier and action name.
$interval = apply_filters( $this->identifier . '_cron_interval', $this->cron_interval );
}
// Adds every 5 minutes to the existing schedules.
$schedules[ $this->identifier . '_cron_interval' ] = [
'interval' => MINUTE_IN_SECONDS * $interval,
/* translators: Cron Interval in minutes. */
'display' => sprintf( esc_html_x( 'Every %d Minutes', 'cron interval description', 'colorio' ), $interval ),
];
return $schedules;
}
/**
* Restart the background process if not already running
* and data exists in the queue.
*/
public function handle_cron_healthcheck() {
if ( $this->is_process_running() ) {
// Background process already running.
exit;
}
if ( $this->is_queue_empty() ) {
// No data to process.
$this->clear_scheduled_event();
exit;
}
$this->handle();
exit;
}
/**
* Schedule event.
*/
protected function schedule_event() {
if ( ! wp_next_scheduled( $this->cron_hook_identifier ) ) {
wp_schedule_event( time(), $this->cron_interval_identifier, $this->cron_hook_identifier );
}
}
/**
* Clear scheduled event.
*/
protected function clear_scheduled_event() {
$timestamp = wp_next_scheduled( $this->cron_hook_identifier );
if ( $timestamp ) {
wp_unschedule_event( $timestamp, $this->cron_hook_identifier );
}
}
/**
* Stop processing queue items, clear cronjob and delete batch.
*/
public function cancel_process() {
if ( ! $this->is_queue_empty() ) {
$batch = $this->get_batch();
$this->delete( $batch->key );
wp_clear_scheduled_hook( $this->cron_hook_identifier );
}
}
/**
* Override this method to perform any actions required on each
* queue item. Return the modified item for further processing
* in the next pass through. Or, return false to remove the
* item from the queue.
*
* @param mixed $item Queue item to iterate over.
*
* @return mixed
*/
abstract protected function task( $item );
}
@@ -0,0 +1,103 @@
<?php
namespace Vrts\Core\Utilities;
class Color_Helpers {
/**
* Converts a color from hex to rgba.
*
* @param string $color The color to convert.
* @param int $opacity The color opacity.
* @param string $return_type The return format, string or array.
*
* @return string|array
*/
public static function hex_to_rgba( $color, $opacity = 1, $return_type = 'array' ) {
$color = str_replace( '#', '', $color );
if ( strlen( $color ) === 3 ) {
$r = hexdec( substr( $color, 0, 1 ) . substr( $color, 0, 1 ) );
$g = hexdec( substr( $color, 1, 1 ) . substr( $color, 1, 1 ) );
$b = hexdec( substr( $color, 2, 1 ) . substr( $color, 2, 1 ) );
} else {
$r = hexdec( substr( $color, 0, 2 ) );
$g = hexdec( substr( $color, 2, 2 ) );
$b = hexdec( substr( $color, 4, 2 ) );
}
$rgba = [ $r, $g, $b, $opacity ];
if ( 'string' === $return_type ) {
return 'rgba(' . implode( ',', $rgba ) . ')';
}
return $rgba;
}
/**
* Converts a color from rgba to hsla.
*
* @param string $color The color to convert.
* @param int $opacity The color opacity.
* @param string $return_type The return format, string or array.
*
* @return string|array
*/
public static function rgba_to_hsla( $color, $opacity = 1, $return_type = 'array' ) {
$r = $color[0] / 255;
$g = $color[1] / 255;
$b = $color[2] / 255;
$min = min( $r, min( $g, $b ) );
$max = max( $r, max( $g, $b ) );
$delta = $max - $min;
$h = 0;
$s = 0;
$l = 0;
if ( $delta > 0 ) {
if ( $max === $r ) {
$h = fmod( ( ( $g - $b ) / $delta ), 6 );
} elseif ( $max === $g ) {
$h = ( $b - $r ) / $delta + 2;
} else {
$h = ( $r - $g ) / $delta + 4;
}
}
$h = round( $h * 60 );
if ( $h < 0 ) {
$h += 360;
}
$l = ( $min + $max ) / 2;
$s = 0 === $delta ? 0 : $delta / ( 1 - abs( 2 * $l - 1 ) );
$s = round( $s * 100, 1 );
$l = round( $l * 100, 1 );
$hsla = [ $h, "$s%", "$l%", $opacity ];
if ( 'string' === $return_type ) {
return 'hsla(' . implode( ',', $hsla ) . ')';
}
return $hsla;
}
/**
* Converts a color from hex to hsla.
*
* @param string $color The color to convert.
* @param int $opacity The color opacity.
* @param string $return_type The return format, string or array.
*
* @return string|array
*/
public static function hex_to_hsla( $color, $opacity = 1, $return_type = 'array' ) {
$rgba = self::hex_to_rgba( $color, $opacity );
return self::rgba_to_hsla( $rgba, $opacity, $return_type );
}
}
@@ -0,0 +1,111 @@
<?php
namespace Vrts\Core\Utilities;
use DateTime;
use DateTimeZone;
class Date_Time_Helpers {
/**
* Get the date and time WordPress native formatted.
*
* @param mixed $date a DateTime string.
*
* @return string Formatted date and time.
*/
public static function get_formatted_date_time( $date = null ) {
if ( null === $date ) {
return null;
}
$date = self::date_from_gmt( $date );
$formatted_date = sprintf(
/* translators: 1: Date, 2: Time. */
esc_html_x( '%1$s at %2$s', 'date at time', 'visual-regression-tests' ),
/* translators: Date format. See https://www.php.net/manual/datetime.format.php */
date_format( $date, esc_html_x( 'Y/m/d', 'date format', 'visual-regression-tests' ) ),
/* translators: Time format. See https://www.php.net/manual/datetime.format.php */
date_format( $date, esc_html_x( 'g:i a', 'time format', 'visual-regression-tests' ) )
);
return $formatted_date;
}
/**
* Get the date and time WordPress native formatted.
*
* @param mixed $date a DateTime string.
*
* @return string Formatted date and time.
*/
public static function get_formatted_relative_date_time( $date = null ) {
if ( null === $date ) {
return null;
}
$date = self::date_from_gmt( $date );
$formatted_date = sprintf(
/* translators: 1: Date, 2: Time. */
esc_html_x( '%1$s at %2$s', 'date at time', 'visual-regression-tests' ),
/* translators: Date format. See https://www.php.net/manual/datetime.format.php */
static::extract_date( $date ),
/* translators: Time format. See https://www.php.net/manual/datetime.format.php */
static::extract_time( $date )
);
return '<time datetime="' . date_format( $date, 'c' ) . '">' . $formatted_date . '</time>';
}
/**
* Get the date WordPress native formatted.
*
* @param mixed $date a DateTime string.
*
* @return DateTime DateTime instance.
*/
private static function date_from_gmt( $date ) {
$date = date_create( $date, new DateTimeZone( 'UTC' ) );
$date->setTimezone( wp_timezone() );
return $date;
}
/**
* Extract the date from a DateTime object.
*
* @param DateTime $input_date a DateTime object.
*
* @return string Formatted date.
*/
private static function extract_date( $input_date ) {
// Get today's date at midnight in the site's timezone.
$today = new DateTime( 'today', wp_timezone() );
// Clone input date and set time to midnight.
$comparison_date = clone $input_date;
$comparison_date->setTime( 0, 0, 0 );
// Calculate the difference in days.
$difference_in_seconds = $comparison_date->getTimestamp() - $today->getTimestamp();
$difference_in_days = (int) round( $difference_in_seconds / ( 60 * 60 * 24 ) );
// Determine if the date is today, tomorrow, or yesterday.
if ( 0 === $difference_in_days ) {
return __( 'Today', 'visual-regression-testing' );
} elseif ( 1 === $difference_in_days ) {
return __( 'Tomorrow', 'visual-regression-testing' );
} elseif ( -1 === $difference_in_days ) {
return __( 'Yesterday', 'visual-regression-testing' );
}
return $input_date->format( 'D, Y/m/d' );
}
/**
* Extract the time from a DateTime object.
*
* @param DateTime $input_date a DateTime object.
*
* @return string Formatted time.
*/
private static function extract_time( $input_date ) {
return $input_date->setTimezone( wp_timezone() )->format( 'g:i a' );
}
}
@@ -0,0 +1,68 @@
<?php
namespace Vrts\Core\Utilities;
class Image_Helpers {
/**
* Get the image height and width string.
*
* @param object $alert The alert object.
*
* @return string
*/
public static function alert_image_hwstring( $alert ) {
$meta = maybe_unserialize( $alert->meta );
$width = $meta['width'] ?? 1265;
$height = $meta['height'] ?? 1800;
return image_hwstring( $width, $height );
}
/**
* Get the image aspect ratio string.
*
* @param object $alert The alert object.
*
* @return int
*/
public static function alert_image_aspect_ratio( $alert ) {
$meta = maybe_unserialize( $alert->meta );
if ( isset( $meta['width'], $meta['height'] ) ) {
return round( $meta['width'] / $meta['height'], 2 );
}
return 1.25;
}
/**
* Get screenshot URL.
*
* @param object $item The alert or test object.
* @param string $type Image type - base, target, comparison.
* @param string $size The size of the image.
*
* @return string
*/
public static function get_screenshot_url( $item, $type, $size = 'full' ) {
$property = "{$type}_screenshot_url";
if ( ! property_exists( $item, $property ) ) {
return '';
}
$url = 'preview' === $size ? maybe_unserialize( $item->meta )['preview_url'] ?? $item->$property : $item->$property;
return self::get_cloudfront_url( $url );
}
/**
* Get the cloudfront URL.
*
* @param string $url The URL.
*
* @return string
*/
public static function get_cloudfront_url( $url ) {
return str_replace( 'https://screenshotter-dev.s3.eu-central-1.amazonaws.com/', 'https://images.vrts.app/', $url );
}
}
@@ -0,0 +1,73 @@
<?php
namespace Vrts\Core\Utilities;
class Sanitization {
/**
* Checkbox sanitization callback.
*
* @param bool $checked Whether the checkbox is checked.
*
* @return bool Whether the checkbox is checked.
*/
public static function sanitize_checkbox( $checked ) {
return $checked ? true : false;
}
/**
* HTML sanitization callback.
*
* @param string $html HTML to sanitize.
*
* @return string
*/
public static function sanitize_html( $html ) {
return wp_filter_post_kses( $html );
}
/**
* No-HTML sanitization callback.
*
* @param string $nohtml The no-HTML content to sanitize.
*
* @return string
*/
public static function sanitize_nohtml( $nohtml ) {
return wp_filter_nohtml_kses( $nohtml );
}
/**
* Number sanitization callback.
*
* @param int $number Number to sanitize.
*
* @return int
*/
public static function sanitize_number_absint( $number ) {
// Ensure $number is an absolute integer (whole number, zero or greater).
return absint( $number );
}
/**
* URL sanitization callback.
*
* @param string $url URL to sanitize.
*
* @return string
*/
public static function sanitize_url( $url ) {
return esc_url_raw( $url );
}
/**
* Multiple emails sanitization callback.
*
* @param string $emails Comma-separated list of emails.
*
* @return string
*/
public static function sanitize_multiple_emails( $emails ) {
$emails = array_filter( array_map( 'sanitize_email', explode( ',', $emails ) ) );
return implode( ', ', $emails );
}
}
@@ -0,0 +1,164 @@
<?php
namespace Vrts\Core\Utilities;
class String_Helpers {
/**
* Converts a string from camel case to kebap case.
*
* @param string $str The string to convert.
*
* @return string
*/
public static function camel_case_to_kebap( $str ) {
return strtolower( preg_replace( '/([a-zA-Z])(?=[A-Z])/', '$1-', $str ) );
}
/**
* Strips all HTML tags including script and style,
* and trims text to a certain number of words.
*
* @param string $str The string to trim and strip.
* @param int $length The string length to return.
*
* @return string
*/
public static function trim_strip( $str = '', $length = 25 ) {
return wp_trim_words( wp_strip_all_tags( $str ), $length, '&hellip;' );
}
/**
* Splits a camel case string.
*
* @param string $str The string to split.
*
* @return string
*/
public static function split_camel_case( $str ) {
$a = preg_split(
'/(^[^A-Z]+|[A-Z][^A-Z]+)/',
$str,
-1,
// no limit for replacement count.
PREG_SPLIT_NO_EMPTY
// don't return empty elements.
| PREG_SPLIT_DELIM_CAPTURE
// don't strip anything from output array.
);
return implode( ' ', $a );
}
/**
* Converts a string from kebap case to camel case.
*
* @param string $str The string to convert.
* @param boolean $capitalize_first_character Sets if the first character should be capitalized.
*
* @return string
*/
public static function kebap_case_to_camel_case( $str, $capitalize_first_character = false ) {
$str = str_replace( ' ', '', ucwords( str_replace( '-', ' ', $str ) ) );
if ( false === $capitalize_first_character ) {
$str[0] = strtolower( $str[0] );
}
return $str;
}
/**
* Removes a prefix from a string.
*
* @param string $prefix The prefix to be removed.
* @param string $str The string to manipulate.
*
* @return string
*/
public static function remove_prefix( $prefix, $str ) {
if ( self::starts_with( $prefix, $str ) ) {
return substr( $str, strlen( $prefix ) );
}
return $str;
}
/**
* Checks if a string starts with a certain string.
*
* @param string $search The string to search for.
* @param string $str The string to look into.
*
* @return boolean Returns true if the subject string starts with the search string.
*/
public static function starts_with( $search, $str ) {
$starts_with = substr( $str, 0, strlen( $search ) );
return $search === $starts_with;
}
/**
* Checks if a string ends with a certain string.
*
* @param string $search The string to search for.
* @param string $str The string to look into.
*
* @return boolean Returns true if the subject string ends with the search string.
*/
public static function ends_with( $search, $str ) {
$search_length = strlen( $search );
$str_length = strlen( $str );
if ( $search_length > $str_length ) {
return false;
}
return 0 === substr_compare( $str, $search, $str_length - $search_length, $search_length );
}
/**
* Remove white space in string.
*
* @param string $str The string to look into.
*
* @return string String without whitespace.
*/
public function remove_white_space( $str ) {
$str = str_replace( "\t", ' ', $str );
$str = str_replace( "\n", '', $str );
$str = str_replace( "\r", '', $str );
while ( stristr( $str, ' ' ) ) {
$str = str_replace( ' ', '', $str );
}
return $str;
}
/**
* Format lines.
*
* @param array $lines Lines to format.
* @param int $tabs Number of tabs for the offest.
*
* @return string Formated lines.
*/
public static function format_lines( $lines, $tabs = 1 ) {
$line_tabs = str_repeat( "\t", $tabs );
$end_tabs = str_repeat( "\t", $tabs - 1 );
$lines = array_map( function ( $line ) use ( $line_tabs ) {
return "\n{$line_tabs}{$line}";
}, $lines);
return implode( '', $lines ) . "\n{$end_tabs}";
}
/**
* Truncate a string.
*
* @param string $str The string to truncate.
* @param int $length The length to truncate to.
* @param string $append The string to append.
*
* @return string
*/
public static function truncate( $str, $length = 100, $append = '...' ) {
$str = trim( $str );
return ( strlen( $str ) > $length ) ? substr( $str, 0, $length - strlen( $append ) ) . $append : $str;
}
}
@@ -0,0 +1,132 @@
<?php
namespace Vrts\Core\Utilities;
use Vrts\Models\Alert;
use Vrts\Models\Test_Run;
class Url_Helpers {
/**
* Get the relative permalink of a post.
*
* @param int $post_id Post ID.
*
* @return string
*/
public static function get_relative_permalink( $post_id ) {
$permalink = get_permalink( $post_id );
return self::make_relative( $permalink );
}
/**
* Make a permalink relative.
*
* @param int $permalink A permalink.
*
* @return string
*/
public static function make_relative( $permalink ) {
$home_url = home_url();
if ( 0 === strpos( $permalink, $home_url ) ) {
$permalink = str_replace( $home_url, '', $permalink );
}
return $permalink;
}
/**
* Get the page URL.
*
* @param string $page Page.
*
* @return string
*/
public static function get_page_url( $page ) {
$page = 'tests' === $page ? 'vrts' : 'vrts-' . $page;
return admin_url( 'admin.php?page=' . $page );
}
/**
* Get the alert page URL.
*
* @param int|Alert $alert_id Alert ID.
* @param int $test_run_id Test run ID.
*
* @return string
*/
public static function get_alert_page( $alert_id, $test_run_id = null ) {
if ( is_null( $test_run_id ) ) {
$alert = Alert::get_item( $alert_id );
$test_run_id = $alert->test_run_id;
}
return add_query_arg( [
'page' => 'vrts-runs',
'run_id' => $test_run_id,
'alert_id' => $alert_id,
], admin_url( 'admin.php' ) );
}
/**
* Get the alerts page URL.
*
* @param int|Test_Run $test_run Test run.
*
* @return string
*/
public static function get_alerts_page( $test_run ) {
$test_run_id = is_object( $test_run ) ? $test_run->id : $test_run;
return add_query_arg( [
'page' => 'vrts-runs',
'run_id' => $test_run_id,
], admin_url( 'admin.php' ) );
}
/**
* Get the run manual test page URL.
*
* @param int $test_id Test id.
*
* @return string
*/
public static function get_run_manual_test_url( $test_id ) {
return add_query_arg( [
'page' => 'vrts',
'action' => 'run-manual-test',
'test_id' => $test_id,
], admin_url( 'admin.php' ) );
}
/**
* Get the disable testing test page URL.
*
* @param int $test_id Test id.
*
* @return string
*/
public static function get_disable_testing_url( $test_id ) {
return add_query_arg( [
'page' => 'vrts',
'action' => 'disable-testing',
'test_id' => $test_id,
], admin_url( 'admin.php' ) );
}
/**
* Get the test run page URL.
*
* @param int $test_run_id Test run id.
*
* @return string
*/
public static function get_test_run_page( $test_run_id ) {
if ( is_object( $test_run_id ) ) {
$test_run_id = $test_run_id->id;
}
return add_query_arg( [
'page' => 'vrts-runs',
'run_id' => $test_run_id,
], admin_url( 'admin.php' ) );
}
}
@@ -0,0 +1,89 @@
<?php
namespace Vrts\Features;
use Vrts\Models\Test;
class Admin_Columns {
/**
* Constructor.
*/
public function __construct() {
add_action( 'admin_init', [ $this, 'init_admin_columns' ] );
}
/**
* Init admin columns.
*/
public function init_admin_columns() {
if ( current_user_can( 'manage_options' ) ) {
$custom_post_types = get_post_types([
'public' => true,
'_builtin' => false,
]);
$post_types = array_merge( [ 'post', 'page' ], $custom_post_types );
foreach ( $post_types as $post_type ) {
add_filter( 'manage_' . $post_type . '_posts_columns', [ $this, 'column_heading' ], 10, 1 );
add_action( 'manage_' . $post_type . '_posts_custom_column', [ $this, 'column_content' ], 10, 2 );
}
}
}
/**
* Add custom columns to the list table.
*
* @param array $columns Array of columns.
*
* @return array
*/
public function column_heading( $columns ) {
$added_columns = [];
$added_columns['vrts_testing_status'] = sprintf(
'<span class="vrts-status" title="%2$s %3$s">%1$s<span class="screen-reader-text">%2$s %3$s</span></span>',
vrts()->get_logo(),
vrts()->get_plugin_info( 'name' ),
__( 'Status', 'visual-regression-tests' )
);
return array_merge( $columns, $added_columns );
}
/**
* Display the content for the given column.
*
* @param string $column_name Column to display the content for.
* @param int $post_id Post to display the column content for.
*/
public function column_content( $column_name, $post_id ) {
switch ( $column_name ) {
case 'vrts_testing_status':
$test_id = Test::get_item_id( $post_id );
$item = (object) Test::get_item( $test_id );
if ( property_exists( $item, 'current_alert_id' ) ) {
$class = null === $item->current_alert_id ? 'vrts-icon-status--running' : 'vrts-icon-status--paused';
$text = null === $item->current_alert_id
? vrts()->get_plugin_info( 'name' ) . '&#13;' . esc_html__( 'Status: Running', 'visual-regression-tests' )
: vrts()->get_plugin_info( 'name' ) . '&#13;' . esc_html__( 'Status: Paused', 'visual-regression-tests' );
printf(
'<div aria-hidden="true" title="%s" class="vrts-icon-status %s"></div>
<span class="screen-reader-text">%s</span>',
esc_html( $text ),
esc_html( $class ),
esc_html( $text )
);
} else {
printf(
'<div aria-hidden="true" title="%s" class="vrts-icon-status"></div>
<span class="screen-reader-text">%s</span>',
esc_html( vrts()->get_plugin_info( 'name' ) . '&#13;' . esc_html__( 'Status: Testing not activated', 'visual-regression-tests' ) ),
esc_html( vrts()->get_plugin_info( 'name' ) . '&#13;' . esc_html__( 'Status: Testing not activated', 'visual-regression-tests' ) )
);
}//end if
return;
}//end switch
}
}
@@ -0,0 +1,56 @@
<?php
namespace Vrts\Features;
class Admin_Header {
/**
* Constructor.
*/
public function __construct() {
add_action('current_screen', function () {
$current_screen = get_current_screen();
if ( isset( $current_screen->id ) && strpos( $current_screen->id, 'vrts' ) !== false ) {
add_action( 'in_admin_header', [ $this, 'add_navigation' ] );
}
});
}
/**
* Add header navigation.
*/
public function add_navigation() {
global $submenu, $submenu_file, $plugin_page;
$menu_items = [];
$base_slug = 'vrts';
if ( isset( $submenu[ $base_slug ] ) ) {
foreach ( $submenu[ $base_slug ] as $sub_item ) {
if ( isset( $sub_item[2] ) && strpos( $sub_item[2], $base_slug ) !== false ) {
$url = admin_url( "admin.php?page={$sub_item[2]}" );
} else {
$url = admin_url( $sub_item[2] );
}
// Setup tab.
$menu_item = [
'text' => $sub_item[0],
'url' => isset( $url ) ? $url : $sub_item[2],
];
// Add state.
if ( $submenu_file === $sub_item[2] || $plugin_page === $sub_item[2] ) {
$menu_item['is_active'] = true;
}
$menu_items[] = $menu_item;
}
}//end if
vrts()->component( 'admin-header', [
'plugin_name' => vrts()->get_plugin_info( 'name' ),
'menu_items' => $menu_items,
]);
}
}
@@ -0,0 +1,75 @@
<?php
namespace Vrts\Features;
class Admin_Notices {
const OPTION_BASE_NAME = 'vrts_admin_notice_dismissed_';
/**
* Constructor.
*/
public function __construct() {
add_action( 'wp_ajax_vrts_admin_notice_dismiss', [ $this, 'wp_ajax_save_dismiss_status_ajax' ] );
}
/**
* Save admin notice dismiss status as option.
*/
public function wp_ajax_save_dismiss_status_ajax() {
$check_nonce = check_ajax_referer( 'vrts_admin_notice_nonce', 'security' );
$view = isset( $_POST['view'] ) ? sanitize_text_field( wp_unslash( $_POST['view'] ) ) : null;
if ( null !== $view && 1 === $check_nonce ) {
$option_id = self::OPTION_BASE_NAME . $view;
update_option( $option_id, true );
wp_die();
}
}
/**
* Get dismissed status of the notification from options.
*
* @param string $admin_notice_view_name The name of the view.
*/
private static function is_dismissed( $admin_notice_view_name ) {
return (bool) get_option( self::OPTION_BASE_NAME . $admin_notice_view_name, false );
}
/**
* Render the admin notification.
*
* @param string $admin_notice_view_name The name of the view.
* @param bool $is_dismissible Notification dismissible option.
* @param array $data Data to pass to the view.
*/
public static function render_notification( $admin_notice_view_name, $is_dismissible = false, $data = [] ) {
$data = array_merge([
'view' => $admin_notice_view_name,
], $data);
if ( $is_dismissible ) {
$is_dismissed = self::is_dismissed( $admin_notice_view_name );
if ( true !== $is_dismissed ) {
vrts()->component( 'admin-notification', $data );
}
} else {
vrts()->component( 'admin-notification', $data );
}
}
/**
* Remove all dismissed status of notifications from options.
*/
public static function delete_dismissed_options() {
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- It's OK.
$wpdb->query(
$wpdb->prepare(
"DELETE FROM `$wpdb->options` WHERE `option_name` Like %s",
$wpdb->esc_like( self::OPTION_BASE_NAME . '%' )
)
);
}
}
@@ -0,0 +1,48 @@
<?php
namespace Vrts\Features;
use Vrts\Core\Utilities\Url_Helpers;
use Vrts\Models\Alert;
class Admin {
/**
* Constructor.
*/
public function __construct() {
add_action( 'admin_menu', [ $this, 'add_main_menu' ] );
add_filter( 'plugin_action_links_' . plugin_basename( vrts()->get_plugin_file() ), [ $this, 'plugin_action_links' ] );
add_action( 'admin_init', 'Vrts\Features\Service::connect_service' );
}
/**
* Add main menu where other sub menus can be added to.
*/
public function add_main_menu() {
$count = Alert::get_total_items_grouped_by_test_run();
add_menu_page(
'VRTs',
$count ? 'VRTs <span class="update-plugins count-' . esc_attr( $count ) . '">' . esc_html( $count ) . '</span>' : 'VRTs',
'manage_options',
'vrts',
'',
//phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
'data:image/svg+xml;base64,' . base64_encode( vrts()->get_logo() ),
80
);
}
/**
* Show action links on the plugin screen.
*
* @param array $links Plugin Action links.
*
* @return array $links Plugin Action links.
*/
public function plugin_action_links( $links ) {
$links['tests'] = '<a href="' . esc_url( Url_Helpers::get_page_url( 'tests' ) ) . '" aria-label="' . esc_attr__( 'Tests', 'visual-regression-tests' ) . '">' . esc_html__( 'Tests', 'visual-regression-tests' ) . '</a>';
$links['settings'] = '<a href="' . esc_url( Url_Helpers::get_page_url( 'settings' ) ) . '" aria-label="' . esc_attr__( 'Settings', 'visual-regression-tests' ) . '">' . esc_html__( 'Settings', 'visual-regression-tests' ) . '</a>';
return $links;
}
}
@@ -0,0 +1,67 @@
<?php
namespace Vrts\Features;
use Vrts\Core\Utilities\Url_Helpers;
use Vrts\Services\Test_Service;
class Bulk_Actions {
/**
* Constructor.
*/
public function __construct() {
add_action( 'admin_init', function () {
$post_types = vrts()->get_public_post_types();
foreach ( $post_types as $post_type ) {
add_filter( 'bulk_actions-edit-' . $post_type, [ $this, 'register_bulk_test_option' ] );
add_filter( 'handle_bulk_actions-edit-' . $post_type, [ $this, 'handle_bulk_optimize_action' ], 10, 3 );
}
} );
}
/**
* Register bulk optimize option.
*
* @param array $bulk_actions Bulk actions.
* @return array
*/
public function register_bulk_test_option( $bulk_actions ) {
$bulk_actions['add-to-vrts'] = __( 'Add to VRTs', 'visual-regression-tests-service' );
return $bulk_actions;
}
/**
* Handle bulk optimize action.
*
* @param string $redirect_to Redirect to.
* @param string $doaction Action.
* @param array $post_ids Post ids.
* @return string
*/
public function handle_bulk_optimize_action( $redirect_to, $doaction, $post_ids ) {
if ( 'add-to-vrts' !== $doaction ) {
return $redirect_to;
}
$service = new Test_Service();
$created_tests = $service->create_tests( $post_ids );
$vrts_url = Url_Helpers::get_page_url( 'tests' );
if ( is_wp_error( $created_tests ) ) {
$redirect_to = add_query_arg([
'new-test-failed' => true,
], $vrts_url);
} else {
$redirect_to = add_query_arg([
'message' => 'success',
'new-tests-added' => true,
'post_ids' => wp_list_pluck( $created_tests, 'post_id' ),
], $vrts_url);
}
return $redirect_to;
}
}
@@ -0,0 +1,112 @@
<?php
namespace Vrts\Features;
use Vrts\Models\Test;
use Vrts\Models\Test_Run;
use Vrts\Services\Test_Run_Service;
use Vrts\Services\Test_Service;
class Cron_Jobs {
/**
* Max tries.
*
* @var int
*/
private $max_tries = 10;
/**
* Wait multiplicator.
*
* @var int
*/
private $wait_multiplicator = 2;
/**
* Initial wait.
*
* @var int
*/
private $initial_wait = 20;
/**
* Constructor.
*/
public function __construct() {
if ( ! wp_next_scheduled( 'vrts_fetch_updates_cron' ) ) {
wp_schedule_event( time(), 'hourly', 'vrts_fetch_updates_cron' );
}
add_action( 'vrts_fetch_updates_cron', [ $this, 'fetch_updates' ] );
add_action( 'vrts_fetch_test_updates', [ $this, 'fetch_test_updates' ], 10, 2 );
add_action( 'vrts_fetch_test_run_updates', [ $this, 'fetch_test_run_updates' ], 10, 2 );
}
/**
* Daily check connection status.
*/
public function fetch_updates() {
$service = new Test_Service();
$service->fetch_and_update_tests();
}
/**
* Remove jobs.
*/
public static function remove_jobs() {
wp_clear_scheduled_hook( 'vrts_connection_check_cron' );
wp_clear_scheduled_hook( 'vrts_fetch_updates_cron' );
}
/**
* Fetch test updates.
*
* @param int $test_id Test id.
* @param int $try_number Try number.
*/
public function fetch_test_updates( $test_id, $try_number = 1 ) {
$test = Test::get_item( $test_id );
if ( empty( $test ) || empty( $test->base_screenshot_date ) ) {
$service = new Test_Service();
$service->fetch_and_update_tests();
if ( $try_number < $this->max_tries ) {
$next_execution = time() + $this->initial_wait * $this->wait_multiplicator * $try_number;
wp_schedule_single_event( $next_execution, 'vrts_fetch_test_updates', [ $test_id, $try_number + 1 ] );
}
}
}
/**
* Fetch test run updates.
*
* @param int $test_run_id Test run id.
* @param int $try_number Try number.
*/
public function fetch_test_run_updates( $test_run_id, $try_number = 1 ) {
$test_run = Test_Run::get_item( $test_run_id );
if ( empty( $test_run ) || empty( $test_run->finished_at ) ) {
$service = new Test_Run_Service();
$service->fetch_and_update_test_runs();
if ( $try_number < $this->max_tries ) {
$next_execution = time() + $this->initial_wait * $this->wait_multiplicator * $try_number;
wp_schedule_single_event( $next_execution, 'vrts_fetch_test_run_updates', [ $test_run_id, $try_number + 1 ] );
}
}
}
/**
* Schedule initial fetch test updates.
*
* @param int $test_id Test id.
*/
public static function schedule_initial_fetch_test_updates( $test_id ) {
wp_schedule_single_event( time(), 'vrts_fetch_test_updates', [ $test_id, 1 ] );
}
/**
* Schedule initial fetch test run updates.
*
* @param int $test_run_id Test run id.
*/
public static function schedule_initial_fetch_test_run_updates( $test_run_id ) {
wp_schedule_single_event( time(), 'vrts_fetch_test_run_updates', [ $test_run_id, 1 ] );
}
}
@@ -0,0 +1,23 @@
<?php
namespace Vrts\Features;
use Vrts\Models\Test_Run;
class Deactivate {
/**
* Constructor.
*/
public function __construct() {
register_deactivation_hook( vrts()->get_plugin_file(), [ $this, 'deactivate' ] );
}
/**
* Deactivate plugin.
*/
public function deactivate() {
Test_Run::delete_all_not_finished();
Service::disconnect_service();
}
}
@@ -0,0 +1,130 @@
<?php
namespace Vrts\Features;
use Vrts\Core\Utilities\Date_Time_Helpers;
use Vrts\Core\Utilities\Image_Helpers;
use Vrts\Core\Utilities\Url_Helpers;
use Vrts\Models\Test;
use Vrts\Features\Subscription;
class Enqueue_Scripts {
/**
* Constructor.
*/
public function __construct() {
add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_scripts' ] );
add_action( 'enqueue_block_editor_assets', [ $this, 'enqueue_block_editor_assets' ] );
}
/**
* Register and Enqueue CSS and JS.
*/
public function enqueue_scripts() {
if ( current_user_can( 'manage_options' ) ) {
$admin_assets_path = vrts()->get_plugin_path( 'build/admin.asset.php' );
if ( ! file_exists( $admin_assets_path ) ) {
// add admin notice.
// You need to run `npm start` or `npm run build`.'.
return;
}
$admin_assets_data = include $admin_assets_path;
// Register CSS.
wp_register_style( 'vrts-admin', vrts()->get_plugin_url( 'build/admin.css' ), [], $admin_assets_data['version'] );
// Register JS.
wp_register_script( 'vrts-admin', vrts()->get_plugin_url( 'build/admin.js' ), $admin_assets_data['dependencies'], $admin_assets_data['version'], true );
// Enqueue CSS.
wp_enqueue_style( 'vrts-admin' );
// Enqueue JS.
wp_enqueue_script( 'vrts-admin' );
// Localize scripts.
wp_localize_script(
'vrts-admin',
'vrts_admin_vars',
[
'rest_url' => esc_url_raw( rest_url( 'vrts/v1' ) ),
'rest_nonce' => wp_create_nonce( 'wp_rest' ),
'pluginUrl' => vrts()->get_plugin_url(),
'currentUserId' => get_current_user_id(),
'onboarding' => apply_filters( 'vrts_onboarding', null ),
]
);
}//end if
}
/**
* Register and Enqueue Editor CSS and JS.
*/
public function enqueue_block_editor_assets() {
if ( current_user_can( 'manage_options' ) ) {
global $post;
if ( ! $post ) {
return;
}
$custom_post_types = get_post_types([
'public' => true,
'_builtin' => false,
]);
$post_types = array_merge( [ 'post', 'page' ], $custom_post_types );
if ( ! in_array( $post->post_type, $post_types, true ) ) {
return;
}
$editor_assets_path = vrts()->get_plugin_path( 'build/editor.asset.php' );
if ( ! file_exists( $editor_assets_path ) ) {
// add editor notice.
// You need to run `npm start` or `npm run build`.'.
return;
}
$editor_assets_data = include $editor_assets_path;
// Register CSS.
wp_register_style( 'vrts-editor', vrts()->get_plugin_url( 'build/editor.css' ), [], $editor_assets_data['version'] );
// Register JS.
wp_register_script( 'vrts-editor', vrts()->get_plugin_url( 'build/editor.js' ), $editor_assets_data['dependencies'], $editor_assets_data['version'], true );
// Enqueue CSS.
wp_enqueue_style( 'vrts-editor' );
// Enqueue JS.
wp_enqueue_script( 'vrts-editor' );
// Localize scripts.
$test_id = Test::get_item_id( $post->ID );
$test = (object) Test::get_item( $test_id );
wp_localize_script(
'vrts-editor',
'vrts_editor_vars',
[
'plugin_name' => vrts()->get_plugin_info( 'name' ),
'rest_url' => esc_url_raw( rest_url() ),
'has_post_alert' => isset( $test->current_alert_id ) ? ! is_null( $test->current_alert_id ) : false,
'base_screenshot_url' => Image_Helpers::get_screenshot_url( $test, 'base' ),
'base_screenshot_date' => Date_Time_Helpers::get_formatted_date_time( $test->base_screenshot_date ?? null ),
'remaining_tests' => Subscription::get_remaining_tests(),
'total_tests' => Subscription::get_total_tests(),
'upgrade_url' => Url_Helpers::get_page_url( 'upgrade' ),
'plugin_url' => Url_Helpers::get_page_url( 'tests' ),
'is_connected' => Service::is_connected(),
'test_status' => Test::get_status_data( $test ),
'screenshot' => Test::get_screenshot_data( $test ),
'test_settings' => [
'test_id' => isset( $test->id ) ? $test->id : null,
'hide_css_selectors' => isset( $test->hide_css_selectors ) ? $test->hide_css_selectors : null,
],
]
);
}//end if
}
}
@@ -0,0 +1,111 @@
<?php
namespace Vrts\Features;
use Vrts\Features\Service;
use Vrts\Tables\Alerts_Table;
use Vrts\Tables\Tests_Table;
use Vrts\Tables\Test_Runs_Table;
class Install {
const ACTIVATION_TRANSIENT = 'vrts_activation';
/**
* Constructor.
*/
public function __construct() {
register_activation_hook( vrts()->get_plugin_file(), [ $this, 'install' ] );
register_activation_hook( vrts()->get_plugin_file(), [ $this, 'set_activation_admin_notice_transient' ] );
add_action( 'admin_notices', [ $this, 'activation_admin_notice' ] );
add_action( 'init', [ $this, 'install' ], 10, 2 );
add_action( 'upgrader_process_complete', [ $this, 'on_upgrade' ], 10, 2 );
add_action( 'vrts_plugin_on_upgrade', [ $this, 'install' ], 10, 2 );
}
/**
* Install plugin.
*
* @param bool $network_wide If the plugin has been activated network wide.
*/
public function install( $network_wide ) {
if ( is_multisite() && $network_wide ) {
global $wpdb;
// Direct DB query and no caching are OK to use in this case.
// @codingStandardsIgnoreStart.
$blog_ids = $wpdb->get_col("SELECT blog_id FROM $wpdb->blogs");
// @codingStandardsIgnoreEnd.
foreach ( $blog_ids as $blog_id ) {
switch_to_blog( $blog_id );
// call here function to install tables, options etc.
$this->install_tables();
$this->connect_service();
restore_current_blog();
}
} else {
// call here function to install tables, options etc.
$this->install_tables();
$this->connect_service();
}
}
/**
* Install plugin tables.
*/
private function install_tables() {
Alerts_Table::install_table();
Tests_Table::install_table();
Test_Runs_Table::install_table();
}
/**
* Install plugin tables.
*/
private function connect_service() {
Service::connect_service();
}
/**
* Set activation transient.
*/
public function set_activation_admin_notice_transient() {
set_transient( self::ACTIVATION_TRANSIENT, true, 5 );
}
/**
* Display activation admin notice.
*/
public function activation_admin_notice() {
if ( get_transient( self::ACTIVATION_TRANSIENT ) ) {
Admin_Notices::render_notification( 'plugin_activated' );
delete_transient( self::ACTIVATION_TRANSIENT );
}
}
/**
* On upgrade.
*
* @param object $upgrader WP_Upgrader instance.
* @param array $options Array of bulk item update data.
*/
public function on_upgrade( $upgrader, $options ) {
if ( 'update' !== $options['action'] ) {
return;
}
if ( 'core' === $options['type'] ) {
return;
}
if ( 'plugin' === $options['type'] && isset( $options['plugins'] ) ) {
foreach ( $options['plugins'] as $plugin ) {
if ( plugin_basename( vrts()->get_plugin_file() ) === $plugin ) {
do_action( 'vrts_plugin_on_upgrade' );
}
}
}
}
}
@@ -0,0 +1,238 @@
<?php
namespace Vrts\Features;
use Vrts\Core\Utilities\Date_Time_Helpers;
use Vrts\Core\Utilities\Image_Helpers;
use Vrts\Core\Utilities\Url_Helpers;
use Vrts\Models\Test;
use Vrts\Services\Test_Service;
class Metaboxes {
/**
* Field key for the "Run Tests" checkbox post meta.
* _ (underscore prefix) represents a protected meta key.
*
* @var string
*/
public static $field_test_status_key = '_vrts_testing_status';
/**
* Field key for the "is new test" post meta.
* _ (underscore prefix) represents a protected meta key.
*
* @var string
*/
public static $field_is_new_test_key = '_vrts_is_new_test';
/**
* Nonce.
*
* @var string
*/
protected $nonce = 'vrts_metabox_nonce';
/**
* Constructor.
*/
public function __construct() {
add_action( 'add_meta_boxes', [ $this, 'add_meta_boxes' ] );
add_action( 'rest_api_init', [ $this, 'add_rest_actions' ] );
add_action( 'save_post', [ $this, 'save_meta_boxes_data' ], 10, 2 );
}
/**
* Get the value of the static key.
*/
public static function get_post_meta_key_status() {
return self::$field_test_status_key;
}
/**
* Get the value of the static key.
*/
public static function get_post_meta_key_is_new_test() {
return self::$field_is_new_test_key;
}
/**
* Is new test.
*
* @param int $post_id WP Post id.
*/
public static function is_new_test( $post_id ) {
$test = Test::get_item_by_post_id( $post_id );
return empty( $test ) ? false : ! $test->service_test_id;
}
/**
* Add meta boxes.
*/
public function add_meta_boxes() {
if ( current_user_can( 'manage_options' ) ) {
$custom_post_types = get_post_types([
'public' => true,
'_builtin' => false,
]);
add_meta_box(
'vrts_post_options_metabox',
vrts()->get_plugin_info( 'name' ),
[ $this, 'render_metabox' ],
array_merge( [ 'post', 'page' ], $custom_post_types ),
'side',
'default',
[ '__back_compat_meta_box' => true ]
);
}
}
/**
* Add rest actions.
*/
public function add_rest_actions() {
if ( current_user_can( 'manage_options' ) ) {
$custom_post_types = get_post_types([
'public' => true,
'_builtin' => false,
]);
foreach ( array_merge( [ 'post', 'page' ], $custom_post_types ) as $custom_post_type ) {
add_action( 'rest_after_insert_' . $custom_post_type, [ $this, 'update_rest_data' ], 10, 2 );
}
}
}
/**
* Render meta box.
*/
public function render_metabox() {
global $post;
$post_id = $post->ID ? $post->ID : 0;
$test_id = Test::get_item_id( $post_id );
$test = (object) Test::get_item( $test_id );
$run_tests_checked = ! is_null( $test_id );
$alert_id = $test->current_alert_id ?? null;
$testing_status_instructions = '';
if ( $alert_id ) {
$alert_link = Url_Helpers::get_alert_page( $alert_id );
$testing_status_instructions .= sprintf(
/* translators: %1$s and %2$s: link wrapper. */
esc_html__( '%1$sView Alert%2$s', 'visual-regression-tests' ),
'<a href="' . esc_url( $alert_link ) . '">',
'</a>'
);
}
vrts()->component('metabox-classic-editor', [
'post_id' => $post_id,
'nonce' => $this->nonce,
'plugin_url' => Url_Helpers::get_page_url( 'tests' ),
'run_tests_checked' => $run_tests_checked,
'field_test_status_key' => self::$field_test_status_key,
'has_post_alert' => isset( $test->current_alert_id ) ? ! is_null( $test->current_alert_id ) : false,
'base_screenshot_url' => Image_Helpers::get_screenshot_url( $test, 'base' ),
'base_screenshot_date' => Date_Time_Helpers::get_formatted_date_time( $test->base_screenshot_date ?? null ),
'testing_status_instructions' => $testing_status_instructions,
'is_new_test' => self::is_new_test( $post_id ),
'remaining_tests' => Subscription::get_remaining_tests(),
'total_tests' => Subscription::get_total_tests(),
'is_connected' => Service::is_connected(),
'test_status' => Test::get_status_data( $test ),
'screenshot' => Test::get_screenshot_data( $test ),
'test_settings' => [
'test_id' => isset( $test->id ) ? $test->id : null,
'hide_css_selectors' => isset( $test->hide_css_selectors ) ? $test->hide_css_selectors : null,
],
]);
}
/**
* Add meta boxes.
*
* @param int $post_id Post ID.
* @param WP_Post $post Post object.
*/
public function save_meta_boxes_data( $post_id, $post ) {
$edit_cap = get_post_type_object( $post->post_type )->cap->edit_post;
if ( ! current_user_can( $edit_cap, $post_id ) ) {
return;
}
$nonce = sanitize_text_field( wp_unslash( $_POST[ $this->nonce ] ?? '' ) );
// Verify nonce. Only valid when using classic editor.
if ( ! wp_verify_nonce( $nonce, $this->nonce ) ) {
return;
}
// Save "Run Tests" checkbox value to post meta.
if ( array_key_exists( self::$field_test_status_key, $_POST ) && '1' === $_POST[ self::$field_test_status_key ] ) {
$service = new Test_Service();
$service->create_test( $post_id );
} else {
// Delete data from tests database table if "Run Tests" checkbox is not checked.
$test_id = Test::get_item_id( $post_id );
if ( $test_id ) {
$service = new Test_Service();
$service->delete_test( (int) $test_id );
}
}
// Save Settings of the test.
$test = (object) Test::get_item_by_post_id( $post_id );
$test_id = isset( $_POST['test_id'] ) ? (int) sanitize_text_field( wp_unslash( $_POST['test_id'] ) ) : 0;
if ( ! empty( $test ) && ! empty( $test->id ) && (int) $test->id === (int) $test_id ) {
$hide_css_selectors = isset( $_POST['hide_css_selectors'] ) ? sanitize_text_field( wp_unslash( $_POST['hide_css_selectors'] ) ) : '';
$test_service = new Test_Service();
$test_service->update_css_hide_selectors( $test_id, $hide_css_selectors );
}
}
/**
* Delete post meta keys.
*/
public static function delete_meta_keys() {
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- It's ok.
$wpdb->delete(
$wpdb->postmeta,
[
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- TODO: Check later
'meta_key' => self::get_post_meta_key_status(),
]
);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- It's ok.
$wpdb->delete(
$wpdb->postmeta,
[
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- TODO: Check later
'meta_key' => self::get_post_meta_key_is_new_test(),
]
);
}
/**
* Update rest data.
*
* @param WP_Post $post Post object.
* @param WP_REST_Request $request Request object.
*/
public function update_rest_data( $post, $request ) {
$vrts_params = $request->get_param( 'vrts' );
if ( array_key_exists( 'hide_css_selectors', $vrts_params ?? [] ) ) {
$hide_css_selectors = $vrts_params['hide_css_selectors'] ? sanitize_text_field( $vrts_params['hide_css_selectors'] ) : '';
$test_id = Test::get_item_id( $post->ID );
$test_service = new Test_Service();
$test_service->update_css_hide_selectors( $test_id, $hide_css_selectors );
}
}
}
@@ -0,0 +1,232 @@
<?php
namespace Vrts\Features;
use Vrts\Models\Alert;
use Vrts\Models\Test;
class Onboarding {
/**
* Constructor.
*/
public function __construct() {
add_action( 'rest_api_init', [ $this, 'register_rest_field' ] );
add_filter( 'vrts_onboarding', [ $this, 'get_onboarding' ] );
}
/**
* Add user meta setting.
*/
public function register_rest_field() {
register_rest_field( 'user', 'vrts_onboarding', [
'get_callback' => function ( $user ) {
return get_user_meta( $user['id'], 'vrts_onboarding', true ) ?: [];
},
'update_callback' => function ( $value, $user ) {
return update_user_meta( $user->ID, 'vrts_onboarding', $value );
},
'schema' => [
'type' => 'object',
'properties' => [
'completed' => [
'type' => 'array',
'default' => [],
],
],
],
] );
}
/**
* Get onboarding.
*
* @return array
*/
public function get_onboarding() {
$onboardins = $this->get_onboardings();
foreach ( $onboardins as $onboarding ) {
if ( $this->has_user_completed_onboarding( $onboarding['id'] ) ) {
continue;
}
if ( call_user_func( $onboarding['permission_callback'] ) ) {
unset( $onboarding['permission_callback'] );
return $onboarding;
}
}
return false;
}
/**
* Has user completed onboarding.
*
* @param string $onboarding_id the onboarding id.
* @return bool
*/
public function has_user_completed_onboarding( $onboarding_id ) {
$onboarding = get_user_meta( get_current_user_id(), 'vrts_onboarding', true ) ?: [];
return in_array( $onboarding_id, $onboarding['completed'] ?? [], true );
}
/**
* Get onboardings.
*
* @return array
*/
public function get_onboardings() {
return [
[
'id' => 'tests-welcome',
'permission_callback' => [ $this, 'should_display_tests_welcome_onboarding' ],
'steps' => [
[
'title' => wp_kses_post( __( '👋 Howdy, welcome aboard!', 'visual-regression-tests' ) ),
'description' => wp_kses_post( __( "With our VRTs plugin, you can effortlessly maintain your website's visual consistency. <br><br><strong>Automatically detect visual changes</strong> and <strong>receive Alerts</strong> to achieve pixel-perfect precision.", 'visual-regression-tests' ) ),
],
[
'title' => wp_kses_post( __( '⏰ Daily Tests & Pro Automations', 'visual-regression-tests' ) ),
'description' => wp_kses_post( __( 'The <strong>Daily Test Run</strong> captures screenshots of your <strong>Test pages</strong> and performs <strong>side-by-side comparisons</strong> to instantly spot changes.<br><br><strong>Upgrade to Pro</strong> to automate Tests for WordPress and plugin updates, integrate deployment pipelines via API, and run Manual Tests on demand.', 'visual-regression-tests' ) ),
],
[
'side' => 'right',
'align' => 'start',
'padding' => 8,
'element' => '#show-modal-add-new',
'title' => wp_kses_post( __( "🚀 Let's get started!", 'visual-regression-tests' ) ),
'description' => wp_kses_post( __( 'Add your first Test here to enable VRTs for the selected page.', 'visual-regression-tests' ) ),
],
],
],
[
'id' => 'first-test',
'permission_callback' => [ $this, 'should_display_first_test_onboarding' ],
'steps' => [
[
'side' => 'bottom',
'align' => 'center',
'element' => '.wp-list-table tbody tr:first-child',
'title' => wp_kses_post( __( '🥳 Yay, you created your first VRT!', 'visual-regression-tests' ) ),
'description' => wp_kses_post( __( 'Starting from tomorrow, your Test will <strong>run daily</strong>, ensuring consistent monitoring of your page.', 'visual-regression-tests' ) ),
],
[
'element' => '.vrts-admin-header__navigation-link[href$="admin.php?page=vrts-settings"]',
'title' => wp_kses_post( __( '🛠️ Fine-tune your setup', 'visual-regression-tests' ) ),
'description' => wp_kses_post( __( 'Further customize your Test configuration and plugin settings for an optimized experience.', 'visual-regression-tests' ) ),
],
],
],
[
'id' => 'run-test',
'permission_callback' => [ $this, 'should_display_run_test_onboarding' ],
'steps' => [
[
'padding' => 2,
'element' => '.wp-list-table .vrts-run-test',
'title' => wp_kses_post( __( '🔬 Run your Test now', 'visual-regression-tests' ) ),
'description' => wp_kses_post( __( 'Want to see your Test in action? <br>Give it a go and run this Test now!', 'visual-regression-tests' ) ),
],
],
],
[
'id' => 'runs-introduction',
'permission_callback' => [ $this, 'should_display_runs_introduction_onboarding' ],
'steps' => [
[
'side' => 'bottom',
'align' => 'center',
'element' => '.vrts-admin-header__navigation-link[href$="admin.php?page=vrts-runs"]',
'title' => wp_kses_post( __( '🚀 Meet the new Runs!', 'visual-regression-tests' ) ),
'description' => wp_kses_post( __( 'Alerts are now bundled into Runs. Get a single report for each daily test, manual test, API trigger, or new: <strong>WordPress & plugin update (Pro)</strong>!', 'visual-regression-tests' ) ),
],
],
],
];
}
/**
* Should display tests welcome onboarding.
*
* @return bool
*/
public function should_display_tests_welcome_onboarding() {
$page = sanitize_text_field( wp_unslash( $_GET['page'] ?? '' ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce verification is not required here.
if ( 'vrts' === $page ) {
$frontpage_id = get_option( 'page_on_front' );
$is_front_page_added = ! is_null( Test::get_item_id( $frontpage_id ) );
$next_id = Test::get_autoincrement_value();
if ( 1 === $next_id || ( $is_front_page_added && 2 === $next_id ) ) {
return true;
}
}
return false;
}
/**
* Should display first test onboarding.
*
* @return bool
*/
public function should_display_first_test_onboarding() {
$page = sanitize_text_field( wp_unslash( $_GET['page'] ?? '' ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce verification is not required here.
$is_new_test_added = isset( $_GET['new-test-added'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce verification is not required here.
if ( 'vrts' === $page && $is_new_test_added ) {
$frontpage_id = get_option( 'page_on_front' );
$is_front_page_added = ! is_null( Test::get_item_id( $frontpage_id ) );
$next_id = Test::get_autoincrement_value();
if ( 2 === $next_id || ( $is_front_page_added && 3 === $next_id ) ) {
return true;
}
}
return false;
}
/**
* Should display run test onboarding.
*
* @return bool
*/
public function should_display_run_test_onboarding() {
$page = sanitize_text_field( wp_unslash( $_GET['page'] ?? '' ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce verification is not required here.
$has_subscription = (bool) Subscription::get_subscription_status();
if ( 'vrts' === $page && $has_subscription ) {
$tests = Test::get_items();
foreach ( $tests as $test ) {
$status = Test::get_calculated_status( $test );
if ( 'scheduled' === $status ) {
return true;
}
}
}
return false;
}
/**
* Should display run introduction onboarding.
*
* @return bool
*/
public function should_display_runs_introduction_onboarding() {
$page = sanitize_text_field( wp_unslash( $_GET['page'] ?? '' ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce verification is not required here.
if ( in_array( $page, [ 'vrts', 'vrts-runs', 'vrts-settings' ], true ) ) {
$has_migrated_alerts = get_option( 'vrts_test_runs_has_migrated_alerts' );
if ( $has_migrated_alerts ) {
return true;
}
}
return false;
}
}
@@ -0,0 +1,103 @@
<?php
namespace Vrts\Features;
use Vrts\Models\Test;
use Vrts\Models\Alert;
use Vrts\Services\Test_Service;
class Post_Update_Actions {
/**
* Constructor.
*/
public function __construct() {
add_action( 'wp_after_insert_post', [ $this, 'resume_test' ] );
add_action( 'trashed_post', [ $this, 'on_trash_post_action' ], 10, 2 );
add_action( 'transition_post_status', [ $this, 'on_transition_post_status_action' ], 10, 3 );
add_action( 'update_option_vrts_remaining_tests', [ $this, 'on_update_option_vrts_remaining_tests_action' ], 10, 2 );
add_action( 'post_updated', [ $this, 'on_post_updated_action' ], 10, 3 );
}
/**
* Add meta boxes.
*
* @param int $post_id Post ID.
*/
public function resume_test( $post_id ) {
// If post has test, update the screenshot to the latest version.
if ( Test::get_item_id( $post_id ) ) {
$service = new Test_Service();
$service->resume_test( $post_id );
}
}
/**
* Delete tests when post is trashed.
*
* @param int $post_id Post ID.
*/
public function on_trash_post_action( $post_id ) {
// If trashed post has test, delete the test too.
$test_id = Test::get_item_id( $post_id );
if ( $test_id ) {
Test::delete( $test_id );
// If an alert exists already, archive it too.
Alert::set_alert_state_for_post_id( $post_id, 1 );
}
}
/**
* Create or delete test remotely when post is published or unpublished.
*
* @param string $new_status New post status.
* @param string $old_status Old post status.
* @param WP_Post $post Post object.
*/
public function on_transition_post_status_action( $new_status, $old_status, $post ) {
// If post has test and no active alerts, update the screenshot to the latest version.
$test = Test::get_item_by_post_id( $post->ID );
if ( $test ) {
if ( 'publish' === $new_status && 'publish' !== $old_status ) {
$service = new Test_Service();
$service->create_remote_test( $post, (array) $test );
}
if ( 'publish' === $old_status && 'publish' !== $new_status ) {
$service = new Test_Service();
$service->delete_remote_test( $test );
}
}
}
/**
* Resume tests when remaining tests option is updated.
*
* @param string $old_value Old option value.
* @param string $value New option value.
*/
public function on_update_option_vrts_remaining_tests_action( $old_value, $value ) {
if ( intval( $old_value ) === 0 && intval( $value ) > 0 ) {
$service = new Test_Service();
$service->resume_stale_tests();
}
}
/**
* Update test URL when post slug is updated.
*
* @param int $post_id Post ID.
* @param WP_Post $post_after Post object after update.
* @param WP_Post $post_before Post object before update.
*/
public function on_post_updated_action( $post_id, $post_after, $post_before ) {
$test = Test::get_item_by_post_id( $post_id );
if ( $test ) {
if ( $post_after->post_name !== $post_before->post_name ) {
$service = new Service();
$service->update_test( $test->service_test_id, [
'url' => get_permalink( $post_id ),
] );
}
}
}
}
@@ -0,0 +1,395 @@
<?php
namespace Vrts\Features;
use Vrts\Models\Alert;
use Vrts\Models\Test;
use Vrts\Services\Test_Service;
class Service {
const DB_VERSION = '1.2';
const SERVICE = 'vrts_service';
const BASE_URL = VRTS_SERVICE_ENDPOINT;
/**
* Connect current website to VRTs Service.
*/
public static function connect_service() {
$option_name = self::SERVICE . '_version';
$installed_version = get_option( $option_name );
if ( self::DB_VERSION !== $installed_version ) {
update_option( $option_name, self::DB_VERSION );
}//end if
if ( ! self::is_connected() ) {
self::create_site();
}
if ( self::is_connected() && ! self::has_secret() ) {
self::create_secret();
}
if ( $installed_version && version_compare( $installed_version, '1.2', '<' ) ) {
$service_project_id = get_option( 'vrts_project_id' );
$service_api_route = 'sites/' . $service_project_id;
self::rest_service_request( $service_api_route, [], 'put' );
}
}
/**
* Rerty connection.
*/
public static function retry_connection() {
return static::create_site( true );
}
/**
* Helper to create site on service.
*
* @return bool
*/
private static function create_site() {
if ( static::is_connected() ) {
return;
}
$time = current_time( 'mysql' );
$rest_base_url = self::get_rest_url();
$service_api_route = 'sites';
$create_token = md5( 'verysecret' . $time );
$parameters = [
'create_token' => $create_token,
'rest_url' => $rest_base_url,
'admin_ajax_url' => admin_url( 'admin-ajax.php' ),
'requested_at' => $time,
];
if ( ! empty( get_option( 'vrts_project_id' ) ) && ! empty( get_option( 'vrts_project_token' ) ) ) {
$parameters['project_id'] = get_option( 'vrts_project_id' );
$parameters['project_token'] = get_option( 'vrts_project_token' );
$parameters['project_secret'] = get_option( 'vrts_project_secret' );
$parameters['tests'] = Test::get_all_service_test_ids();
}
$service_request = self::rest_service_request( $service_api_route, $parameters, 'post' );
delete_option( 'vrts_disconnected' );
if ( 201 === $service_request['status_code'] || 200 === $service_request['status_code'] ) {
$data = $service_request['response'];
update_option( 'vrts_project_id', $data['id'] );
update_option( 'vrts_project_token', $data['token'] );
update_option( 'vrts_project_secret', $data['secret'] ?? null );
Subscription::update_available_tests( $data['remaining_credits'], $data['total_credits'], $data['has_subscription'], $data['tier_id'] );
self::add_homepage_test();
return true;
} else {
update_option( 'vrts_disconnected', 1 );
}
return false;
}
/**
* Connect current website to VRTs Service.
*
* @param string $service_api_route the service api route.
* @param array $parameters the parameters.
* @param string $request_type the request type.
*/
public static function rest_service_request( $service_api_route, $parameters = [], $request_type = '' ) {
$request_url = self::BASE_URL . $service_api_route;
$service_project_id = get_option( 'vrts_project_id' );
$service_project_token = get_option( 'vrts_project_token' );
$response = [];
$args = [
'project_id' => $service_project_id,
'headers' => [
'Content-Type' => 'application/json; charset=utf-8',
'Authorization' => 'Bearer ' . $service_project_token,
],
'body' => wp_json_encode( $parameters ),
'data_format' => 'body',
];
// If project already created, attach project id and service token.
if ( $service_project_id && $service_project_token ) {
$args['project_id'] = $service_project_id;
$args['headers']['Authorization'] = 'Bearer ' . $service_project_token;
}
add_filter( 'http_headers_useragent', [ static::class, 'set_user_agent' ], 10 );
switch ( $request_type ) {
case 'get':
$args = [
'method' => 'GET',
'project_id' => $service_project_id,
'headers' => [
'Authorization' => 'Bearer ' . $service_project_token,
],
'body' => $parameters,
'data_format' => 'body',
];
$data = wp_remote_post( $request_url, $args );
$response = [
'response' => json_decode( wp_remote_retrieve_body( $data ), true ),
'status_code' => wp_remote_retrieve_response_code( $data ),
];
break;
case 'delete':
$args['method'] = 'DELETE';
$data = wp_remote_post( $request_url, $args );
break;
case 'put':
$args['method'] = 'PUT';
$data = wp_remote_post( $request_url, $args );
break;
default:
$data = wp_remote_post( $request_url, $args );
$response = [
'response' => json_decode( wp_remote_retrieve_body( $data ), true ),
'status_code' => wp_remote_retrieve_response_code( $data ),
];
break;
}//end switch
if ( empty( $response ) ) {
$response = [
'response' => $data,
'status_code' => wp_remote_retrieve_response_code( $data ),
];
}
remove_filter( 'http_headers_useragent', [ static::class, 'set_user_agent' ], 10 );
return $response;
}
/**
* Send request to server to resume test.
*
* @param int $post_id the post id.
*/
public static function resume_test( $post_id ) {
$service_test_id = Test::get_service_test_id_by_post_id( $post_id );
if ( $service_test_id ) {
$service_api_route = 'tests/' . $service_test_id . '/resume';
$response = self::rest_service_request( $service_api_route, [], 'post' );
}
}
/**
* Send request to server to delete test.
*
* @param int $service_test_id the service test id.
*/
public static function delete_test( $service_test_id ) {
$service_api_route = 'tests/' . $service_test_id;
$response = self::rest_service_request( $service_api_route, [], 'delete' );
return 200 === $response['status_code'];
}
/**
* Send request to server to update test.
*
* @param int $service_test_id the service test id.
* @param array $data the data.
*/
public static function update_test( $service_test_id, $data ) {
$service_api_route = 'tests/' . $service_test_id;
$response = self::rest_service_request( $service_api_route, $data, 'put' );
return 200 === $response['status_code'];
}
/**
* Send request to server to resume tests.
*/
public static function resume_tests() {
$service_project_id = get_option( 'vrts_project_id' );
$service_api_route = 'sites/' . $service_project_id;
self::rest_service_request( $service_api_route . '/resume', [], 'post' );
}
/**
* Add homepage as a default test.
*/
public static function add_homepage_test() {
$option_name = 'vrts_homepage_added';
$homepage_added = get_option( $option_name );
// If plugin was previously activated, dont add homepage again.
if ( ! $homepage_added ) {
$homepage_id = get_option( 'page_on_front' );
// Save data to custom database table.
$test_service = new Test_Service();
$test_service->create_test( $homepage_id );
update_option( $option_name, 1 );
}
}
/**
* Get rest url for default language if WPML is installed.
*/
private static function get_rest_url() {
// Exlusion for WPML installations.
global $sitepress;
$rest_url = rest_url( 'vrts/v1/service' );
if ( $sitepress ) {
// WPML Get languages.
// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- It's ok.
$wpml_current_lang = apply_filters( 'wpml_current_language', null );
// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- It's ok.
$wpml_default_lang = apply_filters( 'wpml_default_language', null );
// If current language is not default, switch to default language to get the rest url.
if ( $wpml_current_lang !== $wpml_default_lang ) {
$sitepress->switch_lang( $wpml_default_lang );
$rest_url = rest_url( 'vrts/v1/service' );
// Switch back to the current language.
$sitepress->switch_lang( $wpml_current_lang );
}
}
return $rest_url;
}
/**
* Run manual tests.
*
* @param string[] $service_test_ids the service test ids.
* @param array $options the options.
*/
public static function run_manual_tests( $service_test_ids, $options = [] ) {
$service_project_id = get_option( 'vrts_project_id' );
$service_api_route = 'sites/' . $service_project_id . '/trigger';
return self::rest_service_request( $service_api_route, array_merge( $options, [
'ids' => $service_test_ids,
]), 'post' );
}
/**
* Mark alert as false positive.
*
* @param int $alert_id Alert id.
*/
public static function mark_alert_as_false_positive( $alert_id ) {
$alert = Alert::get_item( $alert_id );
$service_api_route = 'tests/' . $alert->screenshot_test_id . '/false-positives';
return self::rest_service_request( $service_api_route, [
'comparison_id' => $alert->comparison_id,
], 'post' );
}
/**
* Unmark alert as false positive.
*
* @param int $alert_id Alert id.
*/
public static function unmark_alert_as_false_positive( $alert_id ) {
$alert = Alert::get_item( $alert_id );
$service_api_route = 'tests/' . $alert->screenshot_test_id . '/false-positives/' . $alert->comparison_id;
return self::rest_service_request( $service_api_route, [], 'delete' );
}
/**
* Get project id from the service.
*/
public static function fetch_updates() {
$service_project_id = get_option( 'vrts_project_id' );
$service_api_route = 'sites/' . $service_project_id . '/updates';
return self::rest_service_request( $service_api_route, [], 'get' );
}
/**
* Get test runs from the service.
*
* @param string[] $test_run_ids the test run ids.
*/
public static function fetch_test_runs( $test_run_ids ) {
$service_project_id = get_option( 'vrts_project_id' );
$service_api_route = 'sites/' . $service_project_id . '/runs';
$service_api_route = add_query_arg( 'ids', implode( ',', $test_run_ids ), $service_api_route );
return self::rest_service_request( $service_api_route, [], 'get' );
}
/**
* Delete project from the service.
*/
public static function disconnect_service() {
$service_project_id = get_option( 'vrts_project_id' );
$service_api_route = 'sites/' . $service_project_id;
$response = self::rest_service_request( $service_api_route, [], 'delete' );
if ( 200 === $response['status_code'] ) {
update_option( 'vrts_disconnected', 1 );
}
}
/**
* Drop the database table for tests.
*/
public static function delete_option() {
delete_option( 'vrts_project_id' );
delete_option( 'vrts_project_token' );
delete_option( 'vrts_project_secret' );
delete_option( 'vrts_create_token' );
delete_option( 'vrts_access_token' );
delete_option( 'vrts_homepage_added' );
delete_option( 'vrts_site_urls' );
delete_option( 'vrts_connection_inactive' );
delete_option( self::SERVICE . '_version' );
}
/**
* Check if external service was able to connect
*/
public static function is_connected() {
return ! (bool) get_option( 'vrts_disconnected' ) && (bool) get_option( 'vrts_project_id' ) && (bool) get_option( 'vrts_project_token' );
}
/**
* Check if secret was created
*/
public static function has_secret() {
return (bool) get_option( 'vrts_project_secret' );
}
/**
* Set user agent for the request.
*
* @param string $user_agent the user agent.
*/
public static function set_user_agent( $user_agent ) {
if ( function_exists( 'vrts' ) ) {
return 'VRTs/' . vrts()->get_plugin_info( 'version' ) . ';' . $user_agent;
} else {
return $user_agent;
}
}
/**
* Create secret for the project
*/
private static function create_secret() {
$service_project_id = get_option( 'vrts_project_id' );
$service_api_route = 'sites/' . $service_project_id . '/secret';
$service_request = self::rest_service_request( $service_api_route, [], 'post' );
if ( 200 === $service_request['status_code'] ) {
update_option( 'vrts_project_secret', $service_request['response']['secret'] ?? null );
}
}
}
@@ -0,0 +1,451 @@
<?php
namespace Vrts\Features;
use Vrts\Core\Utilities\Sanitization;
use Vrts\Core\Utilities\Url_Helpers;
use Vrts\Features\Subscription;
use Vrts\Services\Test_Service;
class Settings_Page {
/**
* Page slug.
*
* @var string
*/
protected $page_slug = 'vrts-settings';
/**
* Constructor.
*/
public function __construct() {
add_action( 'init', [ $this, 'add_settings' ] );
add_action( 'admin_menu', [ $this, 'add_submenu_page' ] );
add_action( 'admin_init', [ $this, 'settings_migration' ] );
add_action( 'add_option_vrts_click_selectors', [ $this, 'do_after_update_click_selectors' ], 10, 2 );
add_action( 'update_option_vrts_click_selectors', [ $this, 'do_after_update_click_selectors' ], 10, 2 );
add_action( 'pre_update_option_vrts_license_key', [ $this, 'do_before_add_license_key' ], 10, 2 );
add_action( 'pre_update_option_vrts_email_update_notification_address', [ $this, 'do_before_updating_email_address' ], 10 );
add_action( 'pre_update_option_vrts_email_api_notification_address', [ $this, 'do_before_updating_email_address' ], 10 );
add_action( 'update_option_vrts_automatic_comparison', [ $this, 'do_after_update_vrts_automatic_comparison' ], 10, 2 );
}
/**
* Add submenu.
*/
public function add_submenu_page() {
$submenu_page = add_submenu_page(
'vrts',
esc_html__( 'Settings', 'visual-regression-tests' ),
esc_html__( 'Settings', 'visual-regression-tests' ),
'manage_options',
$this->page_slug,
[ $this, 'render_page' ]
);
add_action( 'load-' . $submenu_page, [ $this, 'init_notifications' ] );
}
/**
* Render settings page.
*/
public function render_page() {
vrts()->component( 'settings-page', [
'title' => esc_html__( 'Settings', 'visual-regression-tests' ),
'settings_fields' => $this->page_slug,
'settings_sections' => $this->page_slug,
] );
}
/**
* Register settings.
*/
public function add_settings() {
$has_subscription = (bool) Subscription::get_subscription_status();
vrts()->settings()->add_section([
'id' => 'vrts-settings-section-general',
'page' => $this->page_slug,
'title' => '',
]);
// Notice:
// value_type can be one of these 'string', 'boolean', 'integer', 'number', 'array', and 'object'.
// sanitize_callback can be a default wp sanitize function or a custom function from the Sanitization class.
// 'sanitize_callback' => '[ Sanitization::class, 'sanitize_checkbox' ]'.
vrts()->settings()->add_setting([
'type' => 'text',
'id' => 'vrts_click_selectors',
'section' => 'vrts-settings-section-general',
'title' => esc_html__( 'Click Element', 'visual-regression-tests' ),
'description' => sprintf(
'%s<br>%s',
sprintf(
/* translators: %s: link wrapper. */
esc_html__( 'Add a %1$sCSS selector%2$s to click on the first element found before creating a new snapshot.', 'visual-regression-tests' ),
'<a href="https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors" target="_blank">',
'</a>'
),
esc_html__( 'Useful to accept cookie banners or anything else that should be clicked after page load.', 'visual-regression-tests' )
),
'placeholder' => esc_html__( 'e.g.: #accept-cookies', 'visual-regression-tests' ),
'sanitize_callback' => 'sanitize_text_field',
'show_in_rest' => true,
'value_type' => 'string',
'default' => '',
]);
vrts()->settings()->add_setting([
'type' => 'text',
'id' => 'vrts_license_key',
'section' => 'vrts-settings-section-general',
'title' => esc_html__( 'License Key', 'visual-regression-tests' ),
'description' => sprintf(
'%1$s <a href="%2$s" title="%3$s">%3$s</a>',
esc_html__( 'No license key yet?', 'visual-regression-tests' ),
esc_url( Url_Helpers::get_page_url( 'upgrade' ) ),
esc_html__( 'Upgrade here.', 'visual-regression-tests' )
),
'placeholder' => esc_html_x( 'XXXXXXXX-XXXXXXXX-XXXXXXXX-XXXXXXXX', 'license key placeholder', 'visual-regression-tests' ),
'sanitize_callback' => 'sanitize_text_field',
'show_in_rest' => true,
'value_type' => 'string',
'value' => ! empty( vrts()->settings()->get_option( 'vrts_license_key', false ) ) ? 'XXXXXXXX-XXXXXXXX-XXXXXXXX-XXXXXXXX' : '',
'default' => '',
]);
vrts()->settings()->add_section([
'id' => 'vrts-settings-section-triggers',
'page' => $this->page_slug,
'title' => esc_html__( 'Triggers', 'visual-regression-tests' ) . '<span>' . esc_html__( 'When your Tests are run.', 'visual-regression-tests' ) . '</span>',
]);
vrts()->settings()->add_setting([
'type' => 'checkbox',
'id' => 'vrts_automatic_comparison',
'section' => 'vrts-settings-section-triggers',
'title' => esc_html__( 'Schedule', 'visual-regression-tests' ),
'label' => esc_html__( 'Run Tests every 24 hours.', 'visual-regression-tests' ),
'sanitize_callback' => [ Sanitization::class, 'sanitize_checkbox' ],
'show_in_rest' => true,
'value_type' => 'boolean',
'value' => 1,
'default' => 1,
'disabled' => 1,
]);
vrts()->settings()->add_setting([
'type' => 'checkbox',
'id' => 'vrts_updates_comparison',
'section' => 'vrts-settings-section-triggers',
'title' => esc_html__( 'Update', 'visual-regression-tests' ),
'label' => esc_html__( 'Run Tests after WordPress and plugin updates.', 'visual-regression-tests' ),
'sanitize_callback' => [ Sanitization::class, 'sanitize_checkbox' ],
'show_in_rest' => true,
'value_type' => 'boolean',
'value' => $has_subscription,
'default' => $has_subscription,
'disabled' => $has_subscription,
'readonly' => ! $has_subscription,
'is_pro' => $has_subscription,
]);
vrts()->settings()->add_setting([
'type' => 'checkbox',
'id' => 'vrts_api_comparison',
'section' => 'vrts-settings-section-triggers',
'title' => esc_html__( 'API', 'visual-regression-tests' ),
'label' => sprintf(
/* translators: %1$s, %2$s: link wrapper. */
wp_kses_post( __( 'Run Tests with your favorite apps. %1$sRead the docs%2$s.', 'visual-regression-tests' ) ),
'<a href="' . esc_url( 'https://vrts.app/docs/' ) . '" target="_blank">',
'</a>'
),
'sanitize_callback' => [ Sanitization::class, 'sanitize_checkbox' ],
'show_in_rest' => true,
'value_type' => 'boolean',
'value' => $has_subscription,
'default' => $has_subscription,
'disabled' => $has_subscription,
'readonly' => ! $has_subscription,
'is_pro' => $has_subscription,
]);
vrts()->settings()->add_setting([
'type' => 'checkbox',
'id' => 'vrts_manual_comparison',
'section' => 'vrts-settings-section-triggers',
'title' => esc_html__( 'Manual', 'visual-regression-tests' ),
'label' => esc_html__( 'Run Tests on demand.', 'visual-regression-tests' ),
'sanitize_callback' => [ Sanitization::class, 'sanitize_checkbox' ],
'show_in_rest' => true,
'value_type' => 'boolean',
'value' => $has_subscription,
'default' => $has_subscription,
'disabled' => $has_subscription,
'readonly' => ! $has_subscription,
'is_pro' => $has_subscription,
]);
vrts()->settings()->add_section([
'id' => 'vrts-settings-section-notifications',
'page' => $this->page_slug,
'title' => esc_html__( 'Notifications', 'visual-regression-tests' ) . '<span>' . esc_html__( 'Notify team members based on specific Trigger events.', 'visual-regression-tests' ) . '</span>',
]);
vrts()->settings()->add_setting([
'type' => 'text',
'id' => 'vrts_email_notification_address',
'section' => 'vrts-settings-section-notifications',
'title' => esc_html__( 'Schedule', 'visual-regression-tests' ),
'description' => esc_html__( 'Separate multiple email addresses with commas. Or leave blank to disable notifications.', 'visual-regression-tests' ),
'placeholder' => esc_html__( 'Email address(es)', 'visual-regression-tests' ),
'sanitize_callback' => [ Sanitization::class, 'sanitize_multiple_emails' ],
'show_in_rest' => true,
'value_type' => 'string',
'default' => get_bloginfo( 'admin_email' ),
'return_value_callback' => [ $this, 'get_sanitized_emails' ],
]);
vrts()->settings()->add_setting([
'type' => 'text',
'id' => 'vrts_email_update_notification_address',
'section' => 'vrts-settings-section-notifications',
'title' => esc_html__( 'Update', 'visual-regression-tests' ),
'placeholder' => esc_html__( 'Email address(es)', 'visual-regression-tests' ),
'sanitize_callback' => [ Sanitization::class, 'sanitize_multiple_emails' ],
'show_in_rest' => true,
'value_type' => 'string',
'default' => '',
'readonly' => ! $has_subscription,
'is_pro' => $has_subscription,
'return_value_callback' => [ $this, 'get_sanitized_emails' ],
]);
vrts()->settings()->add_setting([
'type' => 'text',
'id' => 'vrts_email_api_notification_address',
'section' => 'vrts-settings-section-notifications',
'title' => esc_html__( 'API', 'visual-regression-tests' ),
'placeholder' => esc_html__( 'Email address(es)', 'visual-regression-tests' ),
'sanitize_callback' => [ Sanitization::class, 'sanitize_multiple_emails' ],
'show_in_rest' => true,
'value_type' => 'string',
'default' => '',
'readonly' => ! $has_subscription,
'is_pro' => $has_subscription,
'return_value_callback' => [ $this, 'get_sanitized_emails' ],
]);
vrts()->settings()->add_setting([
'type' => 'info',
'id' => 'vrts_email_manual_notification_address',
'section' => 'vrts-settings-section-notifications',
'title' => esc_html__( 'Manual', 'visual-regression-tests' ),
'description' => esc_html__( 'Alerts are automatically sent to the user who triggers the Manual Test.', 'visual-regression-tests' ),
'is_pro' => $has_subscription,
]);
}
/**
* Settings migration.
*/
public function settings_migration() {
$has_subscription = (bool) Subscription::get_subscription_status();
$old_cc_email = get_option( 'vrts_email_notification_cc_address' );
$update_email = get_option( 'vrts_email_update_notification_address' );
$api_email = get_option( 'vrts_email_api_notification_address' );
if ( $old_cc_email ) {
$old_cc_email = $this->get_sanitized_emails( $old_cc_email );
$schedule_email = vrts()->settings()->get_option( 'vrts_email_notification_address' );
$schedule_email = array_unique( array_merge( $schedule_email, $old_cc_email ) );
$schedule_email = implode( ', ', $schedule_email );
update_option( 'vrts_email_notification_address', $schedule_email );
delete_option( 'vrts_email_notification_cc_address' );
}
if ( $has_subscription ) {
$schedule_email = vrts()->settings()->get_option( 'vrts_email_notification_address', false );
if ( false === $update_email ) {
update_option( 'vrts_email_update_notification_address', $schedule_email );
}
if ( false === $api_email ) {
update_option( 'vrts_email_api_notification_address', $schedule_email );
}
if ( get_option( 'vrts_license_success' ) ) {
update_option( 'vrts_email_update_notification_address', $schedule_email );
update_option( 'vrts_email_api_notification_address', $schedule_email );
}
}
}
/**
* Update global click selector settings for project in service
*
* @param string $old Old value.
* @param string $new_value New value.
*/
public function do_after_update_click_selectors( $old, $new_value ) {
if ( $old !== $new_value ) {
$service_project_id = get_option( 'vrts_project_id' );
$service_api_route = 'sites/' . $service_project_id;
$parameters = [
'screenshot_options' => [
'clickSelectors' => $new_value,
],
];
$response = Service::rest_service_request( $service_api_route, $parameters, 'put' );
$service = new Test_Service();
$service->resume_tests();
}
}
/**
* Register the Gumroad API key with the service.
*
* @param mixed $new_value new value.
* @param mixed $old old value.
*/
public function do_before_add_license_key( $new_value, $old ) {
// If license key is empty but was previously added.
if ( ! $new_value && $old ) {
self::remove_license_key();
update_option( 'vrts_license_failed', true );
return '';
}
if ( 'XXXXXXXX-XXXXXXXX-XXXXXXXX-XXXXXXXX' === $new_value ) {
return $old;
}
if ( $old !== $new_value ) {
$service_project_id = get_option( 'vrts_project_id' );
$service_api_route = 'sites/' . $service_project_id . '/register';
$parameters = [
'license_key' => $new_value,
];
$response = Service::rest_service_request( $service_api_route, $parameters, 'post' );
$status_code = $response['status_code'];
Subscription::get_latest_status();
if ( 200 !== $status_code ) {
// If new key is not valid, remove the old one.
self::remove_license_key();
update_option( 'vrts_license_failed', true );
return '';
}
update_option( 'vrts_license_success', true );
return $new_value;
}//end if
return $old;
}
/**
* Prevent updating email address if there is no subscription
*
* @param string $value New value.
*/
public function do_before_updating_email_address( $value ) {
$has_subscription = (bool) Subscription::get_subscription_status();
return $has_subscription ? $value : '';
}
/**
* Update automatic comparison settings for project in service
*
* @param string $old Old value.
* @param string $new_value New value.
*/
public function do_after_update_vrts_automatic_comparison( $old, $new_value ) {
if ( $old !== $new_value ) {
$service_project_id = get_option( 'vrts_project_id' );
$service_api_route = 'sites/' . $service_project_id;
$parameters = [
'automatic_comparison' => empty( $new_value ) ? false : true,
];
$response = Service::rest_service_request( $service_api_route, $parameters, 'put' );
}
}
/**
* Remove license key from the service
*/
public static function remove_license_key() {
$service_project_id = get_option( 'vrts_project_id' );
$service_api_route = 'sites/' . $service_project_id . '/unregister';
$response = Service::rest_service_request( $service_api_route, [], 'post' );
Subscription::get_latest_status();
}
/**
* Init notifications.
*/
public function init_notifications() {
if ( true === (bool) get_option( 'vrts_license_success' ) ) {
add_action( 'admin_notices', [ $this, 'render_notification_license_added' ] );
delete_option( 'vrts_license_success' );
} elseif ( true === (bool) get_option( 'vrts_license_failed' ) ) {
add_action( 'admin_notices', [ $this, 'render_notification_license_not_added' ] );
delete_option( 'vrts_license_failed' );
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- It's OK.
} elseif ( isset( $_GET['settings-updated'] ) && true === (bool) $_GET['settings-updated'] ) {
add_action( 'admin_notices', [ $this, 'render_notification_settings_saved' ] );
}
}
/**
* Render Settings saved notification.
*/
public function render_notification_settings_saved() {
Admin_Notices::render_notification( 'settings_saved', false );
}
/**
* Render License added notification.
*/
public function render_notification_license_added() {
Admin_Notices::render_notification( 'license_added', false );
}
/**
* Render License adding failed notification.
*/
public function render_notification_license_not_added() {
Admin_Notices::render_notification( 'license_not_added', false );
}
/**
* Render License adding removed notification.
*/
public function render_notification_license_removed() {
Admin_Notices::render_notification( 'license_removed', false );
}
/**
* Sanitize multiple emails.
*
* @param string $emails The emails.
*
* @return array
*/
public function get_sanitized_emails( $emails ) {
return array_filter( array_map( 'sanitize_email', explode( ',', $emails ) ) );
}
}
@@ -0,0 +1,160 @@
<?php
namespace Vrts\Features;
use Vrts\Features\Service;
use Vrts\Models\Test;
class Subscription {
/**
* Update the number of tests available.
*
* @param mixed $remaining_tests Option value. Must be serializable if non-scalar. Expected to not be SQL-escaped.
* @param mixed $available_tests Option value. Must be serializable if non-scalar. Expected to not be SQL-escaped.
* @param mixed $has_subscription Option value. Must be serializable if non-scalar. Expected to not be SQL-escaped.
* @param mixed $tier_id Option value. Must be serializable if non-scalar. Expected to not be SQL-escaped.
*/
public static function update_available_tests( $remaining_tests = null, $available_tests = null, $has_subscription = null, $tier_id = null ) {
if ( null !== $remaining_tests ) {
update_option( 'vrts_remaining_tests', $remaining_tests );
}
if ( null !== $available_tests ) {
update_option( 'vrts_total_tests', $available_tests );
}
if ( null !== $has_subscription ) {
update_option( 'vrts_has_subscription', (int) $has_subscription );
}
if ( null !== $tier_id ) {
update_option( 'vrts_tier_id', $tier_id );
}
}
/**
* Get the number of tests remaining.
*/
public static function get_remaining_tests() {
return get_option( 'vrts_remaining_tests' );
}
/**
* Get the number of total tests.
*/
public static function get_total_tests() {
return get_option( 'vrts_total_tests' );
}
/**
* Get subscription status.
*/
public static function get_subscription_status() {
return get_option( 'vrts_has_subscription' );
}
/**
* Get subscription tier id.
*/
public static function get_subscription_tier_id() {
return get_option( 'vrts_tier_id' );
}
/**
* Increase number of tests until server updates the number of available tests.
*
* @param int $number Number of tests to increase.
*/
public static function increase_tests_count( $number = 1 ) {
$remaining_tests = get_option( 'vrts_remaining_tests' );
$total_tests = get_option( 'vrts_total_tests' );
if ( $remaining_tests < $total_tests ) {
$remaining_tests += $number;
update_option( 'vrts_remaining_tests', min( $total_tests, $remaining_tests ) );
}
return true;
}
/**
* Decrease number of tests until server updates the number of available tests.
*
* @param int $number Number of tests to decrease.
*/
public static function decrease_tests_count( $number = 1 ) {
$remaining_tests = get_option( 'vrts_remaining_tests' );
if ( $remaining_tests >= 1 ) {
$remaining_tests -= $number;
update_option( 'vrts_remaining_tests', max( 0, $remaining_tests ) );
}
}
/**
* Drop the keys for subscription.
*/
public static function delete_options() {
delete_option( 'vrts_email_notification_address' );
delete_option( 'vrts_email_notification_cc_address' );
delete_option( 'vrts_email_update_notification_address' );
delete_option( 'vrts_email_api_notification_address' );
delete_option( 'vrts_email_manual_notification_address' );
delete_option( 'vrts_click_selectors' );
delete_option( 'vrts_license_key' );
delete_option( 'vrts_automatic_comparison' );
delete_option( 'vrts_updates_comparison' );
delete_option( 'vrts_remaining_tests' );
delete_option( 'vrts_total_tests' );
delete_option( 'vrts_has_subscription' );
delete_option( 'vrts_tier_id' );
}
/**
* Send request to server to get the subscription and tests status.
*/
public static function get_latest_status() {
$local_test_ids = Test::get_all_service_test_ids();
$service_project_id = get_option( 'vrts_project_id' );
$service_api_route = 'sites/' . $service_project_id;
$response = Service::rest_service_request( $service_api_route, [], 'get' );
$remaining_credits = $response['response']['remaining_credits'];
$total_credits = $response['response']['total_credits'];
$has_subscription = $response['response']['has_subscription'];
$tier_id = $response['response']['tier_id'];
// Active test ids returned by service.
$active_test_ids = $response['response']['active_test_ids'];
$paused_test_ids = $response['response']['paused_test_ids'];
$local_only_test_ids = [];
foreach ( $local_test_ids as $test_id ) {
if ( ! $has_subscription ) {
// phpcs:ignore WordPress.PHP.StrictInArray.MissingTrueStrict -- This is a loose comparison by design.
if ( ! in_array( $test_id, $active_test_ids ) && in_array( $test_id, $paused_test_ids ) ) {
Test::pause( $test_id );
}
// phpcs:ignore WordPress.PHP.StrictInArray.MissingTrueStrict -- This is a loose comparison by design.
} elseif ( in_array( $test_id, $paused_test_ids ) ) {
$service_api_route = 'tests/' . $test_id . '/resume';
$response = Service::rest_service_request( $service_api_route, [], 'post' );
Test::unpause( $test_id );
}
// phpcs:ignore WordPress.PHP.StrictInArray.MissingTrueStrict -- This is a loose comparison by design.
if ( $test_id && ! in_array( $test_id, $active_test_ids ) && ! in_array( $test_id, $paused_test_ids ) ) {
$local_only_test_ids[] = $test_id;
}
}
if ( ! empty( $local_only_test_ids ) ) {
Test::clear_remote_test_ids( $local_only_test_ids );
}
if ( array_key_exists( 'status_code', $response ) && 200 === $response['status_code'] ) {
if ( array_key_exists( 'response', $response ) ) {
self::update_available_tests( $remaining_credits, $total_credits, $has_subscription, $tier_id );
}
}
}
}
@@ -0,0 +1,251 @@
<?php
namespace Vrts\Features;
use Vrts\Core\Utilities\Url_Helpers;
use Vrts\List_Tables\Test_Runs_List_Table;
use Vrts\List_Tables\Test_Runs_Queue_List_Table;
use Vrts\Models\Alert;
use Vrts\Models\Test;
use Vrts\Models\Test_Run;
use Vrts\Services\Test_Run_Service;
class Test_Runs_Page {
/**
* Constructor.
*/
public function __construct() {
add_action( 'admin_init', [ $this, 'remove_admin_notices' ], 99 );
add_action( 'admin_menu', [ $this, 'add_submenu_page' ] );
add_action( 'admin_body_class', [ $this, 'add_body_class' ] );
}
/**
* Remove admin notices.
*/
public function remove_admin_notices() {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce verification is not required here.
if ( isset( $_GET['page'] ) && 'vrts-runs' === $_GET['page'] && isset( $_GET['run_id'] ) ) {
remove_all_actions( 'admin_notices' );
}
}
/**
* Add submenu.
*/
public function add_submenu_page() {
$count = Alert::get_total_items_grouped_by_test_run();
$submenu_page = add_submenu_page(
'vrts',
__( 'Runs', 'visual-regression-tests' ),
$count ? esc_html__( 'Runs', 'visual-regression-tests' ) . '&nbsp;<span class="update-plugins" title="' . esc_attr( $count ) . '">' . esc_html( $count ) . '</span>' : esc_html__( 'Runs', 'visual-regression-tests' ),
'manage_options',
'vrts-runs',
[ $this, 'render_page' ],
1
);
//phpcs:ignore WordPress.Security.NonceVerification.Recommended -- It's ok.
if ( ! isset( $_GET['run_id'] ) ) {
add_action( 'load-' . $submenu_page, [ $this, 'screen_option' ] );
add_action( 'load-' . $submenu_page, [ $this, 'init_notifications' ] );
}
}
/**
* Add screen options.
*/
public function screen_option() {
// Set Screen Option.
$option = 'per_page';
$args = [
'default' => 20,
'option' => 'vrts_test_runs_per_page',
];
// screen_option are user meta.
add_screen_option( $option, $args );
}
/**
* Add body class.
*
* @param string $classes Body classes.
*/
public function add_body_class( $classes ) {
//phpcs:ignore WordPress.Security.NonceVerification.Recommended -- It's ok.
if ( isset( $_GET['run_id'] ) ) {
$classes .= ' vrts-test-run-wrap';
}
return $classes;
}
/**
* Render page
*/
public function render_page() {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- It's ok.
$run_id = intval( $_GET['run_id'] ?? 0 );
$run = Test_Run::get_item( $run_id );
if ( $run ) {
$tests = $this->prepare_tests( maybe_unserialize( $run->tests ) );
$alerts = $this->prepare_alerts( $run_id, $tests );
list($alert_id, $alert) = $this->get_alert( $alerts );
$test = $alert ? Test::get_item_by_post_id( $alert->post_id ) : null;
$is_receipt = 'receipt' === $alert_id;
list( $current_pagination, $prev_alert_id, $next_alert_id ) = $this->get_pagination( $alerts, $alert_id );
vrts()->component('test-run-page', [
'run' => $run,
'alerts' => $alerts,
'alert' => $alert,
'is_receipt' => $is_receipt,
'pagination' => [
'prev_alert_id' => $prev_alert_id,
'next_alert_id' => $next_alert_id,
'current' => $current_pagination,
'total' => count( $alerts ),
],
'tests' => $tests,
'test_settings' => [
'test_id' => isset( $test->id ) ? $test->id : null,
'hide_css_selectors' => isset( $test->hide_css_selectors ) ? $test->hide_css_selectors : null,
],
]);
} else {
vrts()->component('test-runs-page', [
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- It's ok.
'search_query' => sanitize_text_field( wp_unslash( $_POST['s'] ?? '' ) ),
'list_table' => new Test_Runs_List_Table(),
'list_queue_table' => new Test_Runs_Queue_List_Table(),
]);
}//end if
}
/**
* Prepare alerts.
*
* @param array $tests Tests.
*/
private function prepare_tests( $tests ) {
if ( is_array( $tests ) && count( $tests ) > 0 && ! is_array( $tests[0] ) ) {
$tests = array_map( function ( $test ) {
$test = (int) $test;
$post_id = Test::get_post_id( $test );
return [
'id' => $test,
'post_id' => $post_id,
'post_title' => get_the_title( $post_id ),
'permalink' => get_permalink( $post_id ),
];
}, $tests );
}
usort( $tests, function ( $a, $b ) {
return $a['id'] > $b['id'] ? -1 : 1;
} );
return $tests;
}
/**
* Prepare alerts.
*
* @param int $run_id Run ID.
* @param array $tests Tests.
*/
private function prepare_alerts( $run_id, $tests ) {
$alerts = Alert::get_items_by_test_run( $run_id );
$alerts_by_post_id = [];
foreach ( $alerts as $alert ) {
$alerts_by_post_id[ $alert->post_id ][] = $alert;
}
$sorted_alerts = [];
foreach ( $tests as $test ) {
if ( isset( $alerts_by_post_id[ $test['post_id'] ] ) ) {
$sorted_alerts = array_merge( $sorted_alerts, $alerts_by_post_id[ $test['post_id'] ] );
unset( $alerts_by_post_id[ $test['post_id'] ] );
}
}
$remaining_alerts = array_values( $alerts_by_post_id );
usort( $remaining_alerts, function ( $a, $b ) {
return $a[0]->post_id > $b[0]->post_id ? -1 : 1;
} );
foreach ( $remaining_alerts as $remaining_alert ) {
$sorted_alerts = array_merge( $sorted_alerts, $remaining_alert );
}
return $sorted_alerts;
}
/**
* Get pagination.
*
* @param array $alerts Alerts.
* @param int $alert_id Alert ID.
*/
private function get_pagination( $alerts, $alert_id ) {
if ( 'receipt' === $alert_id ) {
$current_pagination = count( $alerts );
$prev_alert_id = $alerts[ count( $alerts ) - 1 ]->id ?? 0;
$next_alert_id = 0;
} else {
$current_index = ( array_keys( array_filter( $alerts, function ( $alert ) use ( $alert_id ) {
return $alert->id === $alert_id;
} ) )[0] ?? 0 );
$prev_alert_id = $alerts[ $current_index - 1 ]->id ?? 0;
$next_alert_id = $alerts[ $current_index + 1 ]->id ?? 0;
$current_pagination = $current_index + 1;
if ( ! $next_alert_id ) {
$next_alert_id = 'receipt';
}
}
return [ $current_pagination, $prev_alert_id, $next_alert_id ];
}
/**
* Get alert.
*
* @param array $alerts Alerts.
*/
private function get_alert( $alerts ) {
$first_alert_id = isset( $alerts[0] ) ? $alerts[0]->id : 0;
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Nonce verification is not required here.
$alert_id = sanitize_text_field( wp_unslash( $_GET['alert_id'] ?? $first_alert_id ) );
if ( 'receipt' === $alert_id ) {
return [ 'receipt', null ];
}
$alert = Alert::get_item( $alert_id );
if ( ! $alert ) {
$alert_id = $first_alert_id;
$alert = Alert::get_item( $alert_id );
}
return [ $alert_id, $alert ];
}
/**
* Init notifications.
*/
public function init_notifications() {
if ( ! Service::is_connected() ) {
add_action( 'admin_notices', [ $this, 'render_notification_connection_failed' ] );
}
}
/**
* Render connection_failed Notification.
*/
public function render_notification_connection_failed() {
Admin_Notices::render_notification( 'connection_failed' );
}
}
@@ -0,0 +1,499 @@
<?php
namespace Vrts\Features;
use Vrts\Core\Utilities\Url_Helpers;
use Vrts\List_Tables\Tests_List_Table;
use Vrts\Models\Test;
use Vrts\Features\Subscription;
use Vrts\Services\Test_Service;
use Vrts\Services\Manual_Test_Service;
class Tests_Page {
/**
* Constructor.
*/
public function __construct() {
add_action( 'admin_menu', [ $this, 'add_submenu_page' ] );
add_filter( 'set-screen-option', [ $this, 'set_screen' ], 10, 3 );
add_action( 'wp_link_query', [ $this, 'wp_link_query' ], 10, 2 );
add_action( 'wp_ajax_vrts_test_quick_edit_save', [ $this, 'quick_edit_save' ] );
add_action( 'admin_init', [ $this, 'handle_bulk_actions' ] );
}
/**
* Add submenu.
*/
public function add_submenu_page() {
$submenu_page = add_submenu_page(
'vrts',
__( 'Tests', 'visual-regression-tests' ),
__( 'Tests', 'visual-regression-tests' ),
'manage_options',
'vrts',
[ $this, 'render_page' ],
1
);
remove_submenu_page( 'vrts', 'vrts' );
add_action( 'load-' . $submenu_page, [ $this, 'add_assets' ] );
add_action( 'load-' . $submenu_page, [ $this, 'submit_add_new_test' ] );
add_action( 'load-' . $submenu_page, [ $this, 'submit_run_manual_tests' ] );
add_action( 'load-' . $submenu_page, [ $this, 'submit_retry_connection' ] );
add_action( 'load-' . $submenu_page, [ $this, 'process_column_actions' ] );
add_action( 'load-' . $submenu_page, [ $this, 'init_notifications' ] );
}
/**
* Screen Option
*
* @param mixed $status The value to save instead of the option value. Default false (to skip saving the current option).
* @param string $option The option name.
* @param int $value The option value.
* @return mixed
*/
public static function set_screen( $status, $option, $value ) {
return $value;
}
/**
* Render page
*/
public function render_page() {
vrts()->component('tests-page', [
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- It's ok.
'id' => intval( $_GET['id'] ?? 0 ),
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- It's ok.
'action' => sanitize_text_field( wp_unslash( $_GET['action'] ?? 'list' ) ),
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- It's ok.
'search_query' => sanitize_text_field( wp_unslash( $_POST['s'] ?? '' ) ),
'list_table' => new Tests_List_Table(),
'remaining_tests' => Subscription::get_remaining_tests(),
'is_connected' => Service::is_connected(),
'running_tests_count' => count( Test::get_all_running() ),
]);
}
/**
* Handle the submit of the Add New button.
*/
public function submit_add_new_test() {
if ( ! isset( $_POST['submit_add_new_test'] ) ) {
return;
}
if ( isset( $_POST['_wpnonce'] ) && ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) ), 'vrts_page_tests_nonce' ) ) {
die( esc_html__( 'Are you cheating?', 'visual-regression-tests' ) );
}
if ( ! current_user_can( 'read' ) ) {
wp_die( esc_html__( 'Permission Denied!', 'visual-regression-tests' ) );
}
$errors = [];
$page_url = Url_Helpers::get_page_url( 'tests' );
$post_id = isset( $_POST['post_id'] ) ? sanitize_text_field( wp_unslash( $_POST['post_id'] ) ) : 0;
// Some basic validation.
if ( ! $post_id ) {
$errors[] = esc_html__( 'Error: Post ID is required.', 'visual-regression-tests' );
}
// Bail out if error found.
if ( $errors ) {
wp_safe_redirect( $page_url );
exit;
}
// New or edit?
if ( $post_id ) {
$test_service = new Test_Service();
$insert_test = $test_service->create_test( $post_id );
}
if ( is_wp_error( $insert_test ) ) {
$redirect_to = add_query_arg([
'new-test-failed' => true,
'post_id' => $post_id,
], $page_url);
} else {
$redirect_to = add_query_arg([
'message' => 'success',
'new-test-added' => true,
'post_id' => $post_id,
], $page_url);
}//end if
wp_safe_redirect( $redirect_to );
exit;
}
/**
* Handle the submit of the Run Manual Tests button.
*/
public function submit_run_manual_tests() {
if ( ! isset( $_POST['submit_run_manual_tests'] ) ) {
return;
}
if ( isset( $_POST['_wpnonce'] ) && ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) ), 'submit_run_manual_tests' ) ) {
die( esc_html__( 'Are you cheating?', 'visual-regression-tests' ) );
}
if ( ! current_user_can( 'read' ) ) {
wp_die( esc_html__( 'Permission Denied!', 'visual-regression-tests' ) );
}
$service = new Manual_Test_Service();
$service->run_tests();
$page_url = Url_Helpers::get_page_url( 'runs' );
wp_safe_redirect( $page_url );
exit;
}
/**
* Handle the submit of the Retry connection button.
*/
public function submit_retry_connection() {
if ( ! isset( $_POST['submit_retry_connection'] ) ) {
return;
}
if ( isset( $_POST['_wpnonce'] ) && ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) ), 'vrts_retry_connection_nonce' ) ) {
die( esc_html__( 'Are you cheating?', 'visual-regression-tests' ) );
}
if ( ! current_user_can( 'read' ) ) {
wp_die( esc_html__( 'Permission Denied!', 'visual-regression-tests' ) );
}
$response = Service::retry_connection();
$page_url = Url_Helpers::get_page_url( 'tests' );
wp_safe_redirect( $page_url );
exit;
}
/**
* Handle the submit of process_column_actions.
*/
public function process_column_actions() {
if ( ! isset( $_GET['action'] ) && ! isset( $_GET['test_id'] ) ) {
return;
}
if ( isset( $_POST['_wpnonce'] ) && ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) ), 'vrts_page' ) ) {
die( esc_html__( 'Are you cheating?', 'visual-regression-tests' ) );
}
if ( ! current_user_can( 'read' ) ) {
wp_die( esc_html__( 'Permission Denied!', 'visual-regression-tests' ) );
}
$errors = [];
$page_url = Url_Helpers::get_page_url( 'tests' );
$test_id = isset( $_GET['test_id'] ) ? sanitize_text_field( wp_unslash( $_GET['test_id'] ) ) : 0;
$action = isset( $_GET['action'] ) ? sanitize_text_field( wp_unslash( $_GET['action'] ) ) : 0;
// some basic validation.
if ( ! $test_id ) {
$errors[] = esc_html__( 'Error: Test ID is required.', 'visual-regression-tests' );
}
// bail out if error found.
if ( $errors ) {
$first_error = reset( $errors );
$redirect_to = add_query_arg( [ 'error' => $first_error ], $page_url );
wp_safe_redirect( $redirect_to );
exit;
}
// Disable Testing.
if ( $test_id && 'disable-testing' === $action ) {
$test = Test::get_item( $test_id );
$service = new Test_Service();
$deleted = $service->delete_test( $test_id );
$redirect_to = add_query_arg([
'message' => 'success',
'testing-disabled' => ( Service::is_connected() ? true : false ),
'post_id' => $test->post_id,
], $page_url);
if ( ! $deleted ) {
$redirect_to = add_query_arg( [ 'message' => 'error' ], $page_url );
}
} elseif ( $test_id && 'run-manual-test' === $action ) {
$service = new Manual_Test_Service();
$service->run_tests( [ $test_id ] );
$test = Test::get_item( $test_id );
$redirect_to = add_query_arg([
'page' => 'vrts-runs',
'message' => 'success',
'run-manual-test' => true,
'post_id' => $test->post_id,
], $page_url);
}//end if
if ( empty( $redirect_to ) ) {
$redirect_to = add_query_arg( [ 'message' => 'error' ], $page_url );
}
wp_safe_redirect( $redirect_to );
exit;
}
/**
* This function is used to save hide css selectors in quick edit.
* It will be called from quick_edit_save function.
*
* @return json_decode
*/
public function quick_edit_save() {
if ( isset( $_POST['nonce'] ) && ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['nonce'] ) ), 'vrts_test_quick_edit' ) ) {
exit( 'No naughty business please' );
}
if ( ! isset( $_POST['test_id'] ) || ! (int) $_POST['test_id'] ) {
exit( 'No naughty business please' );
}
$test_id = isset( $_POST['test_id'] ) ? (int) sanitize_text_field( wp_unslash( $_POST['test_id'] ) ) : 0;
$hide_css_selectors = isset( $_POST['hide_css_selectors'] ) ? sanitize_text_field( wp_unslash( $_POST['hide_css_selectors'] ) ) : '';
$current_hide_css_selectors = TEST::get_item( $test_id )->hide_css_selectors ?? '';
if ( $current_hide_css_selectors === $hide_css_selectors ) {
$response = [
'success' => true,
'message' => __( 'No changes made.', 'visual-regression-tests' ),
'hide_css_selectors' => $hide_css_selectors,
];
return wp_die( wp_json_encode( $response ) );
}
$test_service = new Test_Service();
$is_saved = $test_service->update_css_hide_selectors( $test_id, $hide_css_selectors );
if ( $is_saved && ! is_wp_error( $is_saved ) ) {
$success = true;
$message = __( 'Changes saved successfully.', 'visual-regression-tests' );
$post_id = Test::get_item( $test_id )->post_id;
$test_service->resume_test( $post_id );
} else {
$success = false;
$message = __( 'Error while saving the changes.', 'visual-regression-tests' );
}
$test = Test::get_item( $test_id );
$snapshot_status = ! $test->target_screenshot_finish_date ? esc_html__( 'In progress', 'visual-regression-tests' ) : null;
$response = [
'success' => $success,
'message' => $message,
'hide_css_selectors' => $hide_css_selectors,
'snapshot_status' => $snapshot_status,
];
return wp_die( wp_json_encode( $response ) );
}
/**
* Add required assets.
*/
public function add_assets() {
// Remove may previously enqueued wplink script.
wp_deregister_script( 'wplink' );
// Register custom wplink for the Add New functionality.
wp_register_script( 'vrts-wplink', vrts()->get_plugin_url( 'assets/scripts/wplink.js' ), [ 'jquery', 'wp-a11y' ], vrts()->get_plugin_info( 'version' ), false );
// Enqueue custom wplink.
wp_enqueue_script( 'vrts-wplink' );
// Localize custom wplink.
wp_localize_script(
'vrts-wplink',
'wpLinkL10n',
[
'noTitle' => esc_html__( '(no title)', 'visual-regression-tests' ),
'noMatchesFound' => esc_html__( 'No results to enable visual regression testing found.', 'visual-regression-tests' ),
/* translators: Minimum input length in characters to start searching posts in the "Insert/edit link" modal. */
'minInputLength' => (int) esc_html_x( '3', 'minimum input length for searching post links', 'visual-regression-tests' ),
]
);
wp_enqueue_style( 'editor-buttons' );
}
/**
* Modify wp_link_query results.
*
* @param array $results the results.
* @param array $query the query.
*
* @return array the modified results.
*/
public function wp_link_query( $results, $query ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- Required by wp_link_query filter signature.
// phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.NonceVerification.Missing -- It should be ok here.
$is_vrts_filter_query = isset( $_POST['vrts_filter_query'] ) ? filter_var( wp_unslash( $_POST['vrts_filter_query'] ), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE ) : false;
if ( true === $is_vrts_filter_query ) {
foreach ( $results as &$result ) {
$result['run_tests_status'] = Test::exists_for_post( $result['ID'] );
}
}
return $results;
}
/**
* Init notifications.
*/
public function init_notifications() {
$total_test_items = Test::get_total_items();
$frontpage_id = get_option( 'page_on_front' );
$is_front_page_added = ! is_null( Test::get_item_id( $frontpage_id ) );
$is_connected = Service::is_connected();
if ( ! Service::is_connected() ) {
add_action( 'admin_notices', [ $this, 'render_notification_connection_failed' ] );
} elseif ( 0 === $total_test_items || ( 1 === $total_test_items && true === $is_front_page_added ) ) {
add_action( 'admin_notices', [ $this, 'render_notification_get_started' ] );
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- It should be ok here.
$is_new_test_added = isset( $_GET['new-test-added'] ) ? sanitize_text_field( wp_unslash( $_GET['new-test-added'] ) ) : false;
if ( $is_new_test_added ) {
add_action( 'admin_notices', [ $this, 'render_notification_new_test_added' ] );
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- It should be ok here.
$is_new_tests_added = isset( $_GET['new-tests-added'] ) ? sanitize_text_field( wp_unslash( $_GET['new-tests-added'] ) ) : false;
if ( $is_new_tests_added ) {
add_action( 'admin_notices', [ $this, 'render_notification_new_tests_added' ] );
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- It should be ok here.
$is_testing_disabled = isset( $_GET['testing-disabled'] ) ? sanitize_text_field( wp_unslash( $_GET['testing-disabled'] ) ) : false;
if ( $is_testing_disabled ) {
add_action( 'admin_notices', [ $this, 'render_notification_test_disabled' ] );
}
$remaining_tests = Subscription::get_remaining_tests();
if ( '1' === $remaining_tests ) {
add_action( 'admin_notices', [ $this, 'render_notification_unlock_more_tests' ] );
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- It should be ok here.
$is_new_test_failed = isset( $_GET['new-test-failed'] ) ? sanitize_text_field( wp_unslash( $_GET['new-test-failed'] ) ) : false;
if ( ( $is_new_test_failed || '0' === $remaining_tests ) && $is_connected ) {
add_action( 'admin_notices', [ $this, 'render_notification_new_test_failed' ] );
}
}
/**
* Render connection_failed Notification.
*/
public function render_notification_connection_failed() {
Admin_Notices::render_notification( 'connection_failed' );
}
/**
* Render get_started Notification.
*/
public function render_notification_get_started() {
Admin_Notices::render_notification( 'get_started', true );
}
/**
* Render new_test_added Notification.
*/
public function render_notification_new_test_added() {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- It should be ok here.
$post_id = isset( $_GET['post_id'] ) ? intval( $_GET['post_id'] ) : false;
Admin_Notices::render_notification( 'new_test_added', false, [
'page_title' => get_the_title( $post_id ),
]);
}
/**
* Render new_tests_added Notification.
*/
public function render_notification_new_tests_added() {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- It should be ok here.
$post_ids = isset( $_GET['post_ids'] ) ? array_map( 'intval', $_GET['post_ids'] ) : false;
if ( $post_ids ) {
Admin_Notices::render_notification( 'new_tests_added', false, [
'page_titles' => implode( ', ', array_reverse( array_map( function ( $post_id ) {
return get_the_title( $post_id );
}, $post_ids ) ) ),
]);
}
}
/**
* Render new_test_failed Notification.
*/
public function render_notification_new_test_failed() {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- It should be ok here.
$post_id = isset( $_GET['post_id'] ) ? intval( $_GET['post_id'] ) : false;
Admin_Notices::render_notification( 'new_test_failed', false, [
'page_title' => get_the_title( $post_id ),
]);
}
/**
* Render test_disabled Notification.
*/
public function render_notification_test_disabled() {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- It should be ok here.
$post_id = isset( $_GET['post_id'] ) ? intval( $_GET['post_id'] ) : false;
Admin_Notices::render_notification( 'test_disabled', false, [
'page_title' => get_the_title( $post_id ),
'post_id' => intval( $post_id ),
]);
}
/**
* Render unlock_more_tests Notification.
*/
public function render_notification_unlock_more_tests() {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- It should be ok here.
Admin_Notices::render_notification( 'unlock_more_tests', false, [
'total_tests' => Subscription::get_total_tests(),
'remaining_tests' => Subscription::get_remaining_tests(),
]);
}
/**
* Handle bulk actions.
*/
public function handle_bulk_actions() {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- It should be ok here.
$action = ! empty( $_REQUEST['action'] ) ? $_REQUEST['action'] : '';
// phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- It should be ok here.
$page = ! empty( $_REQUEST['page'] ) ? $_REQUEST['page'] : '';
if ( 'vrts-tests_list_table' === $page && 'run-manual-test' === $action ) {
$nonce = sanitize_text_field( wp_unslash( $_REQUEST['_wpnonce'] ?? '' ) );
if ( ! wp_verify_nonce( $nonce, 'bulk-tests' ) ) {
return;
}
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Should be okay for now.
$test_ids = wp_unslash( $_POST['id'] ?? 0 );
if ( 0 === $test_ids ) {
return;
}
$manual_test_service = new Manual_Test_Service();
$manual_test_service->run_tests( $test_ids );
$page_url = Url_Helpers::get_page_url( 'runs' );
wp_safe_redirect( $page_url );
exit;
}
}
}
@@ -0,0 +1,218 @@
<?php
namespace Vrts\Features;
use Vrts\Models\Test;
class Tests {
/**
* Constructor.
*/
public function __construct() {
// Allows developers to run tests by calling `do_action( 'vrts_run_tests' )`.
add_action( 'vrts_run_tests', [ $this, 'run_api_tests' ] );
add_action( 'upgrader_process_complete', [ $this, 'run_upgrader_tests' ], 10, 99 );
}
/**
* Run api tests.
*
* @param string $notes Notes.
*/
public static function run_api_tests( $notes = '' ) {
self::run_tests( 'api', $notes );
}
/**
* Run upgrader tests.
*
* @param \WP_Upgrader $upgrader Upgrader.
* @param array $options Options.
*/
public static function run_upgrader_tests( $upgrader, $options ) {
$updates = [];
if ( 'update' === $options['action'] ) {
switch ( $options['type'] ) {
case 'plugin':
if ( isset( $options['plugins'] ) ) {
if ( is_array( $options['plugins'] ) ) {
foreach ( $options['plugins'] as $plugin ) {
$updates[] = static::add_plugin( $plugin );
}
} else {
$updates[] = static::add_plugin( $options['plugins'] );
}
}
if ( isset( $options['plugin'] ) ) {
$updates[] = static::add_plugin( $options['plugin'] );
}
break;
case 'theme':
if ( isset( $options['themes'] ) ) {
if ( is_array( $options['themes'] ) ) {
foreach ( $options['themes'] as $theme ) {
$updates[] = static::add_theme( $theme );
}
} else {
$updates[] = static::add_theme( $options['themes'] );
}
}
if ( isset( $options['theme'] ) ) {
$updates[] = static::add_theme( $options['theme'] );
}
break;
case 'core':
$new_version = static::get_wp_version();
$updates[] = [
'type' => 'core',
'version' => $new_version,
];
break;
case 'translation':
if ( isset( $options['translations'] ) ) {
if ( is_array( $options['translations'] ) ) {
foreach ( $options['translations'] as $translation ) {
$updates[] = static::add_translation( $translation );
}
} else {
$updates[] = static::add_translation( $options['translations'] );
}
}
if ( isset( $options['translation'] ) ) {
$updates[] = static::add_translation( $options['translation'] );
}
break;
}//end switch
}//end if
if ( ! empty( $updates ) ) {
self::run_tests( 'update', null, $updates );
}
}
/**
* Prepare plugin for updates.
*
* @param string $plugin Plugin.
*
* @return array
*/
private static function add_plugin( $plugin ) {
$plugin_data = get_plugin_data( WP_PLUGIN_DIR . '/' . $plugin );
$new_version = $plugin_data['Version'];
$name = $plugin_data['Name'];
$slug = dirname( plugin_basename( $plugin ) );
return [
'type' => 'plugin',
'name' => $name,
'slug' => $slug,
'version' => $new_version,
];
}
/**
* Prepare theme for updates.
*
* @param string $theme Theme.
*
* @return array
*/
private static function add_theme( $theme ) {
$theme_data = wp_get_theme( $theme );
$new_version = $theme_data->get( 'Version' );
$name = $theme_data->get( 'Name' );
$slug = $theme;
return [
'type' => 'theme',
'name' => $name,
'slug' => $slug,
'version' => $new_version,
];
}
/**
* Prepare translation for updates.
*
* @param array $translation Translation.
*
* @return array
*/
private static function add_translation( $translation ) {
$type = $translation['type'];
$slug = $translation['slug'];
$language = $translation['language'];
if ( 'plugin' === $type ) {
$plugin_data = static::get_plugin_data( $slug );
$name = $plugin_data['Name'];
} elseif ( 'theme' === $type ) {
$theme_data = wp_get_theme( $slug );
$name = $theme_data->get( 'Name' );
} else {
$name = 'WordPress';
}
return [
'type' => $type,
'name' => $name,
'slug' => $slug,
'language' => $language,
];
}
/**
* Get plugin data.
*
* @param string $plugin_slug_or_file Plugin slug or file.
*
* @return array
*/
private static function get_plugin_data( $plugin_slug_or_file ) {
$plugin_file = WP_PLUGIN_DIR . '/' . $plugin_slug_or_file;
$plugin_data = get_plugin_data( $plugin_file );
if ( empty( $plugin_data['Name'] ) ) {
$plugins = get_plugins();
foreach ( $plugins as $file => $local_plugin_data ) {
$slug = dirname( $file );
if ( $slug === $plugin_slug_or_file ) {
$plugin_data = $local_plugin_data;
break;
}
}
}
return $plugin_data;
}
/**
* Run tests.
*
* @param string $trigger Trigger.
* @param string $trigger_notes Trigger notes.
* @param array $trigger_meta Trigger meta.
*/
private static function run_tests( $trigger, $trigger_notes, $trigger_meta = null ) {
$has_subscription = Subscription::get_subscription_status();
if ( ! $has_subscription ) {
return false;
}
$tests = Test::get_all_running();
$service_test_ids = wp_list_pluck( $tests, 'service_test_id' );
Service::run_manual_tests( $service_test_ids, [
'trigger' => $trigger,
'trigger_notes' => $trigger_notes,
'trigger_meta' => $trigger_meta,
] );
}
/**
* Get WordPress version.
*
* @return string
*/
public static function get_wp_version() {
return ( function () {
require ABSPATH . WPINC . '/version.php';
return $wp_version;
} )();
}
}
@@ -0,0 +1,27 @@
<?php
namespace Vrts\Features;
class Translations {
/**
* Constructor.
*/
public function __construct() {
add_action( 'init', [ $this, 'load_plugin_textdomain' ] );
add_action( 'init', [ $this, 'set_script_translations' ] );
}
/**
* Load Localisation files.
*/
public function load_plugin_textdomain() {
load_plugin_textdomain( 'visual-regression-tests', false, plugin_basename( dirname( VRTS_PLUGIN_FILE ) ) . '/languages' );
}
/**
* Set script translations.
*/
public function set_script_translations() {
wp_set_script_translations( 'vrts-editor', 'visual-regression-tests' );
}
}
@@ -0,0 +1,46 @@
<?php
namespace Vrts\Features;
use Vrts\Features\Subscription;
class Upgrade_Page {
/**
* Page slug.
*
* @var string
*/
protected $page_slug = 'vrts-upgrade';
/**
* Constructor.
*/
public function __construct() {
add_action( 'admin_menu', [ $this, 'add_submenu_page' ] );
}
/**
* Add submenu.
*/
public function add_submenu_page() {
add_submenu_page(
'vrts',
__( 'Upgrade', 'visual-regression-tests' ),
__( 'Upgrade', 'visual-regression-tests' ),
'manage_options',
$this->page_slug,
[ $this, 'render_page' ]
);
}
/**
* Render upgrade page.
*/
public function render_page() {
vrts()->component( 'upgrade-page', [
'title' => esc_html__( 'Upgrade', 'visual-regression-tests' ),
'has_subscription' => Subscription::get_subscription_status(),
'tier_id' => Subscription::get_subscription_tier_id(),
] );
}
}
@@ -0,0 +1,341 @@
<?php
namespace Vrts\List_Tables;
use Vrts\Core\Utilities\Date_Time_Helpers;
use Vrts\Core\Utilities\Url_Helpers;
use Vrts\Models\Alert;
use Vrts\Models\Test;
use Vrts\Models\Test_Run;
if ( ! class_exists( 'WP_List_Table' ) ) {
require_once ABSPATH . 'wp-admin/includes/class-wp-list-table.php';
}
/**
* List table class.
*/
class Test_Runs_List_Table extends \WP_List_Table {
/**
* Tests.
*
* @var array
*/
protected $tests = [];
/**
* Alerts.
*
* @var array
*/
protected $alerts = [];
/**
* Parent construct.
*/
public function __construct() {
parent::__construct([
'singular' => 'vrts-run',
'plural' => 'vrts-runs',
'ajax' => false,
]);
}
/**
* Get table classes.
*/
public function get_table_classes() {
return [ 'vrts-test-runs-list-table', 'widefat', 'fixed', $this->_args['plural'] ];
}
/**
* Message to show if no designation found.
*
* @return void
*/
public function no_items() {
esc_html_e( 'No Runs finished.', 'visual-regression-tests' );
}
/**
* Get the column names.
*
* @return array
*/
public function get_columns() {
$columns = [
'cb' => '',
'title' => esc_html__( 'Test Run', 'visual-regression-tests' ),
'trigger' => esc_html__( 'Trigger', 'visual-regression-tests' ),
'status' => esc_html__( 'Status', 'visual-regression-tests' ),
];
return $columns;
}
/**
* Get sortable columns
*
* @return array
*/
public function get_sortable_columns() {
$sortable_columns = [
'title' => [ 'finished_at', true ],
'trigger' => [ 'trigger', true ],
];
return $sortable_columns;
}
/**
* Gets the list of views available on this table.
*
* @return array
*/
public function get_views() {
$base_link = Url_Helpers::get_page_url( 'runs' );
$links = [
'all' => [
'title' => esc_html__( 'All', 'visual-regression-tests' ),
'link' => $base_link,
'count' => Test_Run::get_total_items(),
],
'changes-detected' => [
'title' => esc_html__( 'Changes detected', 'visual-regression-tests' ),
'link' => "{$base_link}&status=changes-detected",
'count' => Test_Run::get_total_items( 'changes-detected' ),
],
];
$status_links = [];
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- It's status request.
$filter_status_query = ( isset( $_REQUEST['status'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['status'] ) ) : 'all' );
foreach ( $links as $key => $link ) {
$current_class = ( $filter_status_query === $key ? 'class="current" ' : '' );
$link = sprintf(
'<a %shref="%s">%s <span class="count">(%s)</span></a>',
$current_class,
$link['link'],
$link['title'],
$link['count']
);
$status_links[ $key ] = $link;
}
return $status_links;
}
/**
* Prepare the class items
*
* @return void
*/
public function prepare_items() {
$columns = $this->get_columns();
$hidden = [];
$sortable = $this->get_sortable_columns();
$this->_column_headers = [ $columns, $hidden, $sortable ];
$per_page = $this->get_items_per_page( 'vrts_test_runs_per_page', 20 );
$current_page = $this->get_pagenum();
$offset = ( $current_page - 1 ) * $per_page;
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- It's the list order parameter.
$order = isset( $_REQUEST['order'] ) && 'asc' === $_REQUEST['order'] ? 'ASC' : 'DESC';
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- It's the list order by parameter.
$order_by = isset( $_REQUEST['orderby'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['orderby'] ) ) : 'finished_at';
// phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.NonceVerification.Missing -- It's the list search query parameter.
$filter_status_query = isset( $_REQUEST['status'] ) && '' !== $_REQUEST['status'] ? sanitize_text_field( wp_unslash( $_REQUEST['status'] ) ) : null;
$args = [
'offset' => $offset,
'number' => $per_page,
'order' => $order,
'orderby' => $order_by,
'filter_status' => $filter_status_query,
];
$this->items = Test_Run::get_items( $args );
$total_items = 0;
if ( null !== $args['filter_status'] ) {
$total_items = Test_Run::get_total_items( $filter_status_query );
} else {
$total_items = Test_Run::get_total_items();
}
$this->set_pagination_args([
'total_items' => $total_items,
'per_page' => $per_page,
]);
}
/**
* Generates content for a single row of the table.
*
* @param object|array $item The current item.
*/
public function single_row( $item ) {
$classes = 'iedit test-run-row';
$current_time = time();
$finished_at = strtotime( $item->finished_at );
$is_new_run = $current_time - $finished_at < 20 * MINUTE_IN_SECONDS;
?>
<tr
id="test-<?php echo esc_attr( $item->id ); ?>"
class="<?php echo esc_attr( $classes ); ?>"
data-test-run-id="<?php echo esc_attr( $item->id ); ?>"
data-test-run-new="<?php echo esc_attr( $is_new_run ? 'true' : 'false' ); ?>"
<?php echo $item->unread_alerts_count > 0 ? 'data-has-alerts' : ''; ?>
>
<?php $this->single_row_columns( $item ); ?>
</tr>
<?php
}
/**
* Generates the required HTML for a list of row action links.
*
* @param string[] $actions An array of action links.
* @param bool $always_visible Whether the actions should be always visible.
*
* @return string The HTML for the row actions.
*/
protected function row_actions( $actions, $always_visible = false ) {
$action_count = count( $actions );
if ( ! $action_count ) {
return '';
}
$mode = get_user_setting( 'posts_list_mode', 'list' );
if ( 'excerpt' === $mode ) {
$always_visible = true;
}
$output = '<div class="' . ( $always_visible ? 'row-actions visible' : 'row-actions' ) . '">';
$i = 0;
foreach ( $actions as $action => $link ) {
$output .= "<span class='$action'>{$link}</span>";
}
$output .= '</div>';
// $output .= '<button type="button" class="toggle-row"><span class="screen-reader-text">' .
// * translators: Hidden accessibility text. */
// esc_html__( 'Show more details', 'visual-regression-tests' ) .
// '</span></button>';
return $output;
}
/**
* Render the status icon column
*
* @param object $item column item.
*
* @return string
*/
public function column_cb( $item ) {
$status = Test_Run::get_calculated_status( $item );
$icons = [
'has-alerts' => 'warning',
'passed' => 'yes-alt',
];
$icon = $icons[ $status ] ?? 'info';
return sprintf(
'<span class="dashicons dashicons-%s vrts-runs-status--%s"></span>',
$icon,
$status
);
}
/**
* Render the designation name column.
*
* @param object $item column item.
*
* @return string
*/
public function column_title( $item ) {
$actions = [];
$actions['details'] = sprintf(
'<a class="vrts-show-test-run-details" href="%s">%s</a>',
esc_url( Url_Helpers::get_test_run_page( $item->id ) ),
esc_html__( 'Show Details', 'visual-regression-tests' )
);
$row_actions = sprintf(
'<strong><a class="row-title vrts-show-test-run-details" href="%1$s">%2$s</a></strong><div class="vrts-test-runs-title-subline">%3$s</div> %4$s',
esc_url( Url_Helpers::get_test_run_page( $item->id ) ),
sprintf( $item->title ),
Date_Time_Helpers::get_formatted_relative_date_time( $item->finished_at ),
$this->row_actions( $actions, true )
);
return $row_actions;
}
/**
* Render the trigger column.
*
* @param object $item column item.
*
* @return string
*/
public function column_trigger( $item ) {
$trigger_title = Test_Run::get_trigger_title( $item );
$trigger_note = Test_Run::get_trigger_note( $item );
return sprintf(
'<span class="vrts-test-run-trigger vrts-test-run-trigger--%s">%s</span>%s',
esc_attr( $item->trigger ),
esc_html( $trigger_title ),
empty( $trigger_note ) ? '' : sprintf('<p class="vrts-test-run-trigger-notes" title="%1$s">%1$s</p>',
$trigger_note
)
);
}
/**
* Render the status column.
*
* @param object $item column item.
*
* @return string
*/
public function column_status( $item ) {
$tests_count = count( maybe_unserialize( $item->tests ) ?? [] );
if ( $item->alerts_count > 0 ) {
$status_class = 'paused';
$status_text = esc_html__( 'Changes detected ', 'visual-regression-tests' ) . sprintf( '(%s)', $item->alerts_count );
} else {
$status_class = 'running';
$status_text = esc_html__( 'No changes', 'visual-regression-tests' );
}
$status_instructions = sprintf(
// translators: %s: number of alerts, %s: number of tests.
esc_html( _n( '%s Test', '%s Tests', $tests_count, 'visual-regression-tests' ) ),
$tests_count
);
return sprintf(
'<div class="vrts-testing-status-wrapper"><p class="vrts-testing-status"><span class="%s">%s</span></p><p class="vrts-testing-status">%s</p></div>',
'vrts-testing-status--' . $status_class,
$status_text,
$status_instructions
);
}
}
@@ -0,0 +1,256 @@
<?php
namespace Vrts\List_Tables;
use Vrts\Core\Utilities\Date_Time_Helpers;
use Vrts\Core\Utilities\Url_Helpers;
use Vrts\Models\Test;
use Vrts\Models\Test_Run;
if ( ! class_exists( 'WP_List_Table' ) ) {
require_once ABSPATH . 'wp-admin/includes/class-wp-list-table.php';
}
/**
* List table class.
*/
class Test_Runs_Queue_List_Table extends \WP_List_Table {
/**
* Parent construct.
*/
public function __construct() {
parent::__construct([
'singular' => 'vrts-run-queue',
'plural' => 'vrts-run-queues',
'ajax' => false,
]);
}
/**
* Get table classes.
*/
public function get_table_classes() {
return [ 'vrts-test-runs-list-table', 'vrts-test-runs-list-queue-table', 'widefat', 'fixed', $this->_args['plural'] ];
}
/**
* Message to show if no designation found.
*
* @return void
*/
public function no_items() {
printf(
/* translators: %1$s, %2$s link wrapper. */
esc_html__( 'No Runs in the queue. %1$sAdd Tests%2$s to get started.', 'visual-regression-tests' ),
'<a href="' . esc_url( Url_Helpers::get_page_url( 'tests' ) ) . '">',
'</a>'
);
}
/**
* Get the column names.
*
* @return array
*/
public function get_columns() {
$columns = [
'cb' => '',
'title' => esc_html__( 'Title', 'visual-regression-tests' ),
'trigger' => esc_html__( 'Trigger', 'visual-regression-tests' ),
'status' => esc_html__( 'Test Status', 'visual-regression-tests' ),
];
return $columns;
}
/**
* Gets the list of views available on this table.
*
* @return array
*/
public function get_views() {
$base_link = Url_Helpers::get_page_url( 'runs' );
$links = [
'all' => [
'title' => esc_html__( 'Queue', 'visual-regression-tests' ),
'link' => $base_link,
'count' => count( Test_Run::get_queued_items() ),
],
];
$status_links = [];
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- It's status request.
$filter_status_query = ( isset( $_REQUEST['status'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['status'] ) ) : 'all' );
foreach ( $links as $key => $link ) {
$current_class = ( $filter_status_query === $key ? 'class="current" ' : '' );
$link = sprintf(
'<a %shref="%s">%s <span class="count">(%s)</span></a>',
$current_class,
$link['link'],
$link['title'],
$link['count']
);
$status_links[ $key ] = $link;
}
return $status_links;
}
/**
* Prepare the class items
*
* @return void
*/
public function prepare_items() {
$columns = $this->get_columns();
$hidden = [];
$sortable = $this->get_sortable_columns();
$this->_column_headers = [ $columns, $hidden, $sortable ];
$this->items = Test_Run::get_queued_items();
}
/**
* Generates content for a single row of the table.
*
* @param object|array $item The current item.
*/
public function single_row( $item ) {
$classes = 'iedit';
$status = Test_Run::get_calculated_status( $item );
?>
<tr id="test-<?php echo esc_attr( $item->id ); ?>" class="<?php echo esc_attr( $classes ); ?>" data-vrts-test-run-status="<?php echo esc_attr( $status ); ?>" data-test-run-id="<?php echo esc_attr( $item->id ); ?>">
<?php $this->single_row_columns( $item ); ?>
</tr>
<?php
}
/**
* Generates the table navigation above or below the table
*
* @param string $which The location of the navigation: Either 'top' or 'bottom'.
*/
protected function display_tablenav( $which ) {
// Don't display the table nav.
}
/**
* Render the status icon column
*
* @param object $item column item.
*
* @return string
*/
public function column_cb( $item ) {
$status = Test_Run::get_calculated_status( $item );
$icons = [
'scheduled' => 'clock',
'running' => 'update',
];
$icon = $icons[ $status ] ?? 'info';
return sprintf(
'<span class="dashicons dashicons-%s vrts-runs-status--%s"></span>',
$icon,
$status
);
}
/**
* Render the designation name column.
*
* @param object $item column item.
*
* @return string
*/
public function column_title( $item ) {
$actions = [];
$status = Test_Run::get_calculated_status( $item );
if ( 'running' === $status ) {
$scheduled_at = __( 'In Progress', 'visual-regression-tests' );
} else {
$scheduled_at = Date_Time_Helpers::get_formatted_relative_date_time( $item->scheduled_at );
}
$row_actions = sprintf(
'<strong><span class="row-title">%1$s</a></strong><div class="row-title-subline">%2$s</div>',
$item->title,
$scheduled_at
);
return $row_actions;
}
/**
* Render the trigger column.
*
* @param object $item column item.
*
* @return string
*/
public function column_trigger( $item ) {
$trigger_title = Test_Run::get_trigger_title( $item );
$trigger_note = Test_Run::get_trigger_note( $item );
return sprintf(
'<span class="vrts-test-run-trigger vrts-test-run-trigger--%s">%s</span><p class="vrts-test-run-trigger-notes" title="%3$s">%3$s</p>',
esc_attr( $item->trigger ),
esc_html( $trigger_title ),
$trigger_note
);
}
/**
* Render the status column.
*
* @param object $item column item.
*
* @return string
*/
public function column_status( $item ) {
$test_run_status = Test_Run::get_calculated_status( $item );
$number_of_tests = count( maybe_unserialize( $item->tests ) ?? [] );
if ( 0 === $number_of_tests ) {
$number_of_tests = Test::get_all_running( true );
}
if ( 'running' === $test_run_status ) {
$class = 'waiting';
$text = '';
$instructions = sprintf(
'<span>%s</span>',
sprintf(
// translators: %1$s: link start to test runs page. %2$s: link end to test runs page.
wp_kses( __( '%1$sRefresh page%2$s to see results', 'visual-regression-tests' ), [ 'a' => [ 'href' => [] ] ] ),
'<a href="' . esc_url( Url_Helpers::get_page_url( 'runs' ) ) . '">',
'</a>'
)
);
} else {
$class = 'waiting';
$text = esc_html__( 'Pending', 'visual-regression-tests' );
$instructions = sprintf(
'<a href="%1$s">%2$s</a> | <a href="%3$s">%4$s</a>',
// translators: %s: number of tests.
esc_url( Url_Helpers::get_page_url( 'tests' ) ),
sprintf(
/* translators: %s Test. Test count */
esc_html( _n( '%s Test', '%s Tests', $number_of_tests, 'visual-regression-tests' ) ),
$number_of_tests
),
esc_url( Url_Helpers::get_page_url( 'settings' ) ),
esc_html__( 'Edit configuration', 'visual-regression-tests' )
);
}//end if
return sprintf(
'<div class="vrts-testing-status-wrapper"><div class="vrts-testing-status"><span class="%s">%s</span></div><div>%s</div></div>',
'vrts-testing-status--' . $class,
$text,
$instructions
);
}
}
@@ -0,0 +1,430 @@
<?php
namespace Vrts\List_Tables;
use Vrts\Core\Utilities\Url_Helpers;
use Vrts\Models\Test;
use Vrts\Features\Service;
use Vrts\Features\Subscription;
use Vrts\Services\Test_Service;
if ( ! class_exists( 'WP_List_Table' ) ) {
require_once ABSPATH . 'wp-admin/includes/class-wp-list-table.php';
}
/**
* List table class.
*/
class Tests_List_Table extends \WP_List_Table {
/**
* Parent construct.
*/
public function __construct() {
parent::__construct([
'singular' => 'test',
'plural' => 'tests',
'ajax' => false,
]);
}
/**
* Get table classes.
*/
public function get_table_classes() {
return [ 'widefat', 'fixed', $this->_args['plural'] ];
}
/**
* Message to show if no designation found.
*
* @return void
*/
public function no_items() {
esc_attr_e( 'No tests found.', 'visual-regression-tests' );
}
/**
* Default column values if no callback found.
*
* @param object $item comment column item.
* @param string $column_name column name.
*
* @return string
*/
public function column_default( $item, $column_name ) {
switch ( $column_name ) {
case 'post_title':
return $item->post_title;
case 'internal_url':
$parsed_internal_url = wp_parse_url( get_permalink( $item->post_id ) );
$internal_url = $parsed_internal_url['path'];
return sprintf(
'<strong><a href="%1$s" title="%2$s" target="_blank">%3$s</a></strong>',
get_post_permalink( $item->post_id ),
esc_html__( 'Open the page in a new tab', 'visual-regression-tests' ),
$internal_url
);
case 'status':
return $this->render_column_status( $item );
case 'base_screenshot_date':
return $this->render_column_snapshot( $item );
default:
return isset( $item->$column_name ) ? $item->$column_name : '';
}//end switch
}
/**
* Get the column names.
*
* @return array
*/
public function get_columns() {
$columns = [
'cb' => '<input type="checkbox" />',
'post_title' => esc_html__( 'Title', 'visual-regression-tests' ),
'internal_url' => esc_html__( 'Path', 'visual-regression-tests' ),
'base_screenshot_date' => esc_html__( 'Snapshot', 'visual-regression-tests' ),
'status' => esc_html__( 'Test Status', 'visual-regression-tests' ),
];
return $columns;
}
/**
* Render the designation name column.
*
* @param object $item column item.
*
* @return string
*/
public function column_post_title( $item ) {
$actions = [];
$is_connected = Service::is_connected();
$actions['edit'] = sprintf(
'<a href="%s" data-id="%d" title="%s">%s</a>',
get_edit_post_link( $item->post_id ),
$item->id,
esc_html__( 'Edit this page', 'visual-regression-tests' ),
esc_html__( 'Edit Page', 'visual-regression-tests' )
);
$actions['inline hide-if-no-js'] = sprintf(
'<button type="button" class="button-link editinline" aria-label="%s" aria-expanded="false">%s</button>',
/* translators: %s: Page Title. */
esc_attr( sprintf( __( 'Quick edit &#8220;%s&#8221; inline', 'visual-regression-tests' ), $item->post_title ) ),
__( 'Quick&nbsp;Edit', 'visual-regression-tests' )
);
if ( $is_connected ) {
$actions['trash'] = sprintf(
'<a href="%s" data-id="%d" title="%s">%s</a>',
Url_Helpers::get_disable_testing_url( $item->id ),
$item->id,
esc_html__( 'Disable testing for this page', 'visual-regression-tests' ),
esc_html__( 'Disable Testing', 'visual-regression-tests' )
);
}
$row_actions = sprintf(
'<strong><a class="row-title" href="%1$s" title="%2$s">%3$s</a></strong> %4$s',
get_edit_post_link( $item->post_id ),
esc_html__( 'Edit this page', 'visual-regression-tests' ),
$item->post_title,
$this->row_actions( $actions )
);
$quickedit_hidden_fields = "
<div class='hidden' id='inline_{$item->id}'>
<div class='hide_css_selectors'>$item->hide_css_selectors</div>
</div>";
return $row_actions . $quickedit_hidden_fields;
}
/**
* Get sortable columns
*
* @return array
*/
public function get_sortable_columns() {
$sortable_columns = [
'post_title' => [ 'post_title', true ],
'status' => [ 'status', true ],
'base_screenshot_date' => [ 'base_screenshot_date', true ],
];
return $sortable_columns;
}
/**
* Set the bulk actions
*
* @return array
*/
public function get_bulk_actions() {
$actions = [
'set-status-disable' => esc_html__( 'Disable Testing', 'visual-regression-tests' ),
];
if ( Subscription::get_subscription_status() && count( Test::get_all_running() ) > 0 ) {
$actions = array_merge(
[ 'run-manual-test' => esc_html__( 'Run Test', 'visual-regression-tests' ) ],
$actions
);
}
return $actions;
}
/**
* Process bulk actions.
*
* @return void
*/
public function process_bulk_action() {
// verify the nonce.
$nonce = sanitize_text_field( wp_unslash( $_REQUEST['_wpnonce'] ?? '' ) );
if ( ! wp_verify_nonce( $nonce, 'bulk-' . $this->_args['plural'] ) ) {
return;
}
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Should be okay for now.
$test_ids = wp_unslash( $_POST['id'] ?? 0 );
if ( 0 === $test_ids ) {
return;
}
if ( 'set-status-disable' === $this->current_action() ) {
foreach ( $test_ids as $test_id ) {
$item = Test::get_item( $test_id );
if ( $item ) {
$service = new Test_Service();
$service->delete_test( (int) $item->id );
}
}
}
}
/**
* Render the checkbox column
*
* @param object $item column item.
*
* @return string
*/
public function column_cb( $item ) {
return sprintf(
'<input type="checkbox" name="id[]" value="%d" />',
$item->id
);
}
/**
* Gets the list of views available on this table.
*
* @return array
*/
public function get_views() {
$base_link = Url_Helpers::get_page_url( 'tests' );
$links = [
'all' => [
'title' => esc_html__( 'All', 'visual-regression-tests' ),
'link' => $base_link,
'count' => Test::get_total_items(),
],
'changes-detected' => [
'title' => esc_html__( 'Changes detected', 'visual-regression-tests' ),
'link' => "{$base_link}&status=changes-detected",
'count' => Test::get_total_items( 'changes-detected' ),
],
];
$status_links = [];
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- It's status request.
$filter_status_query = ( isset( $_REQUEST['status'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['status'] ) ) : 'all' );
foreach ( $links as $key => $link ) {
$current_class = ( $filter_status_query === $key ? 'class="current" ' : '' );
$link = sprintf(
'<a %shref="%s">%s <span class="count">(%s)</span></a>',
$current_class,
$link['link'],
$link['title'],
$link['count']
);
$status_links[ $key ] = $link;
}
return $status_links;
}
/**
* Add extra markup in the toolbars before or after the list.
*
* @param string $which helps you decide if you add the markup after (bottom) or before (top) the list.
*/
public function extra_tablenav( $which ) {
}
/**
* Prepare the class items
*
* @return void
*/
public function prepare_items() {
$columns = $this->get_columns();
$hidden = [];
$sortable = $this->get_sortable_columns();
$this->_column_headers = [ $columns, $hidden, $sortable ];
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- It's the list order parameter.
$order = isset( $_REQUEST['order'] ) && 'asc' === $_REQUEST['order'] ? 'ASC' : 'DESC';
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- It's the list order by parameter.
$order_by = isset( $_REQUEST['orderby'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['orderby'] ) ) : 'id';
// phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.NonceVerification.Missing -- It's the list search query parameter.
$search_query = isset( $_POST['s'] ) && '' !== $_POST['s'] ? sanitize_text_field( wp_unslash( $_POST['s'] ) ) : null;
// phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.NonceVerification.Missing -- It's the list search query parameter.
$filter_status_query = isset( $_REQUEST['status'] ) && '' !== $_REQUEST['status'] ? sanitize_text_field( wp_unslash( $_REQUEST['status'] ) ) : null;
$args = [
'number' => -1,
'order' => $order,
'orderby' => $order_by,
's' => $search_query,
'filter_status' => $filter_status_query,
];
// Process any bulk actions.
$this->process_bulk_action();
$this->items = Test::get_items( $args );
$total_items = count( $this->items );
$this->set_pagination_args([
'total_items' => $total_items,
// we set it to a high number to avoid pagination.
'per_page' => 100000,
]);
}
/**
* Generates content for a single row of the table.
*
* @param object|array $item The current item.
*/
public function single_row( $item ) {
$classes = 'iedit';
?>
<tr id="test-<?php echo esc_attr( $item->id ); ?>" class="<?php echo esc_attr( $classes ); ?>">
<?php $this->single_row_columns( $item ); ?>
</tr>
<?php
}
/**
* Outputs the hidden row displayed when inline editing
*
* @global string $mode List table view mode.
*/
public function inline_edit() {
$screen = $this->screen;
?>
<form method="get">
<table style="display: none"><tbody id="inlineedit">
<?php
$hclass = 'test';
$inline_edit_classes = "inline-edit-row inline-edit-row-$hclass";
$quick_edit_classes = "quick-edit-row quick-edit-row-$hclass inline-edit-{$screen->post_type}";
$classes = $inline_edit_classes . ' ' . $quick_edit_classes;
?>
<tr id="inline-edit" style="display: none" class="<?php echo esc_attr( $classes ); ?>">
<td colspan="<?php echo esc_attr( $this->get_column_count() ); ?>" class="colspanchange">
<div class="inline-edit-wrapper" role="region" aria-labelledby="quick-edit-legend">
<fieldset class="inline-edit-col-left">
<legend class="inline-edit-legend" id="quick-edit-legend"><?php esc_html_e( 'Quick Edit', 'visual-regression-tests' ); ?></legend>
<div class="inline-edit-col">
<label><?php esc_html_e( 'Hide elements from VRTs' ); ?></label>
<textarea name="hide_css_selectors" placeholder="<?php esc_html_e( 'e.g.: .lottie, #ads', 'visual-regression-tests' ); ?>" rows="4" cols="50"></textarea>
<p>
<?php
printf(
/* translators: %1$s, %2$s: strong element wrapper. */
esc_html__( '%1$sExclude elements on this page:%2$s ', 'visual-regression-tests' ),
'<strong>',
'</strong>'
);
printf(
/* translators: %1$s, %2$s: link wrapper. */
esc_html__( 'Add %1$sCSS selectors%2$s (as comma separated list) to exclude elements from VRTs when a new snapshot gets created.', 'visual-regression-tests' ),
'<a href="https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors" target="_blank">',
'</a>'
);
?>
</p>
</div>
</fieldset>
<div class="submit inline-edit-save">
<?php wp_nonce_field( 'vrts_test_quick_edit', '_vrts_test_quick_edit_nonce', false ); ?>
<button type="button" class="button button-primary save"><?php esc_html_e( 'Update' ); ?></button>
<button type="button" class="button cancel"><?php esc_html_e( 'Cancel' ); ?></button>
<span class="spinner"></span>
<input type="hidden" name="screen" value="<?php echo esc_attr( $screen->id ); ?>" />
<div class="notice notice-error notice-alt inline hidden">
<p class="error"></p>
</div>
</div>
</div>
</td>
</tr>
</tbody>
</table>
</form>
<?php
}
/**
* Render the status column.
*
* @param object $item column item.
*
* @return string
*/
private function render_column_status( $item ) {
$status_data = Test::get_status_data( $item );
return sprintf(
'<div class="vrts-testing-status-wrapper"><p class="vrts-testing-status"><span class="%s">%s</span></p><p class="vrts-testing-status">%s</p></div>',
'vrts-testing-status--' . $status_data['class'],
$status_data['text'],
$status_data['instructions']
);
}
/**
* Render the snapshot column.
*
* @param object $item column item.
*
* @return string
*/
private function render_column_snapshot( $item ) {
$screenshot_data = Test::get_screenshot_data( $item );
return sprintf(
'<div class="vrts-testing-status-wrapper"><p class="vrts-testing-status">%s</p><p class="vrts-testing-status">%s</p></div>',
$screenshot_data['text'],
$screenshot_data['instructions']
);
}
}
@@ -0,0 +1,581 @@
<?php
namespace Vrts\Models;
use Vrts\Tables\Alerts_Table;
use Vrts\Services\Test_Service;
use Vrts\Tables\Test_Runs_Table;
use Vrts\Tables\Tests_Table;
/**
* Model Alert Page.
*/
class Alert {
/**
* Get all test items from database
*
* @param array $args Optional.
*
* @return object
*/
public static function get_items( $args = [] ) {
global $wpdb;
$alerts_table = Alerts_Table::get_table_name();
$test_runs_table = Test_Runs_Table::get_table_name();
$defaults = [
's' => '',
'number' => 20,
'offset' => 0,
'orderby' => 'id',
'order' => 'DESC',
'filter_status' => 0,
];
$args = wp_parse_args( $args, $defaults );
// 0 = Open
// 1 = Archived.
// 2 = False Positive.
switch ( $args['filter_status'] ?? null ) {
case 'archived':
$alert_states = [ 1, 2 ];
break;
case 'all':
$alert_states = [];
break;
default:
$alert_states = [ 0 ];
break;
}
if ( ! empty( $alert_states ) ) {
$alert_states_placeholders = implode( ', ', array_fill( 0, count( $alert_states ), '%d' ) );
$where = $wpdb->prepare(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- It's ok.
"WHERE alert_state IN ($alert_states_placeholders)",
$alert_states
);
} else {
$where = 'WHERE 1=1';
}
if ( ! empty( $args['s'] ) ) {
$where .= $wpdb->prepare(
' AND ( title LIKE %s OR test_run_title LIKE %s )',
'%' . $wpdb->esc_like( $args['s'] ) . '%',
'%' . $wpdb->esc_like( $args['s'] ) . '%'
);
}
if ( isset( $args['ids'] ) ) {
$where .= $wpdb->prepare(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- It's ok.
' AND id IN (' . implode( ',', array_fill( 0, count( $args['ids'] ), '%d' ) ) . ')',
$args['ids']
);
}
if ( isset( $args['test_run_id'] ) ) {
$where .= $wpdb->prepare(
' AND test_run_id = %d',
$args['test_run_id']
);
}
$whitelist_orderby = [ 'id', 'title', 'differences', 'target_screenshot_finish_date' ];
$whitelist_order = [ 'ASC', 'DESC' ];
$orderby = in_array( $args['orderby'], $whitelist_orderby, true ) ? $args['orderby'] : 'id';
$order = in_array( $args['order'], $whitelist_order, true ) ? $args['order'] : 'DESC';
$orderby = "ORDER BY $orderby $order";
$limit = $args['number'] > 100 ? 100 : $args['number'];
$limits = $wpdb->prepare(
'LIMIT %d, %d',
$args['offset'],
$limit
);
$alert_title = sprintf(
"CONCAT( '%s', alert.id ) as title",
esc_html__( 'Alert #', 'visual-regression-tests' )
);
$run_title = sprintf(
"CONCAT( '%s', run.id ) as test_run_title",
esc_html__( 'Run #', 'visual-regression-tests' )
);
$query = "
SELECT
*
FROM (
SELECT
alert.id,
$alert_title,
alert.post_id,
alert.screenshot_test_id,
alert.target_screenshot_url,
alert.target_screenshot_finish_date,
alert.base_screenshot_url,
alert.base_screenshot_finish_date,
alert.comparison_screenshot_url,
alert.comparison_id,
alert.differences,
alert.alert_state,
alert.test_run_id,
alert.meta,
$run_title
FROM $alerts_table as alert
LEFT JOIN $test_runs_table as run ON run.id = alert.test_run_id
) alerts
$where
$orderby
$limits
";
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Query is prepared above.
$items = $wpdb->get_results( $query );
return $items;
}
/**
* Get a single test from database
*
* @param int $id the id of the item.
*
* @return object
*/
public static function get_item( $id = 0 ) {
global $wpdb;
$alerts_table = Alerts_Table::get_table_name();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- It's ok.
return $wpdb->get_row(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- It's ok
$wpdb->prepare( "SELECT * FROM $alerts_table WHERE id = %d LIMIT 1", $id )
);
}
/**
* Get multiple alerts from database by id
*
* @param array $ids the ids of the items.
*
* @return array
*/
public static function get_items_by_ids( $ids = [] ) {
global $wpdb;
$alerts_table = Alerts_Table::get_table_name();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- It's ok.
return $wpdb->get_results(
$wpdb->prepare(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- It's ok.
"SELECT * FROM $alerts_table WHERE id IN (" . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ')',
$ids
)
);
}
/**
* Get multiple alerts from database by test run id
*
* @param int $id the id of the test run.
*
* @return array
*/
public static function get_items_by_test_run( $id = 0 ) {
global $wpdb;
$alerts_table = Alerts_Table::get_table_name();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- It's ok.
return $wpdb->get_results(
$wpdb->prepare(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- It's ok.
"SELECT * FROM $alerts_table WHERE test_run_id = %d ORDER BY post_id DESC",
$id
)
);
}
/**
* Get latest alert ids by post ids
*
* @param array $post_ids the ids of the posts.
* @param int $alert_state the state of the item.
*
* @return array
*/
public static function get_latest_alert_ids_by_post_ids( $post_ids = [], $alert_state = 0 ) {
global $wpdb;
$alerts_table = Alerts_Table::get_table_name();
$placeholders = implode( ', ', array_fill( 0, count( $post_ids ), '%d' ) );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- It's ok.
return $wpdb->get_results(
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- It's ok.
$wpdb->prepare(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- It's ok.
"SELECT id, post_id FROM $alerts_table WHERE alert_state = %d AND post_id IN ($placeholders) ORDER BY id DESC",
array_merge(
$alert_state,
$post_ids
)
)
);
}
/**
* Get total test items from database
*
* @param int $filter_status_query the id of status.
* @param int $test_run_id the id of the test run.
*
* @return array
*/
public static function get_total_items( $filter_status_query = null, $test_run_id = null ) {
global $wpdb;
$alerts_table = Alerts_Table::get_table_name();
// 0 = Open
// 1 = Archived.
// 2 = False Positive.
switch ( $filter_status_query ?? null ) {
case 'archived':
$alert_states = [ 1, 2 ];
break;
case 'all':
$alert_states = [];
break;
default:
$alert_states = [ 0 ];
break;
}
if ( ! empty( $alert_states ) ) {
$alert_states_placeholders = implode( ', ', array_fill( 0, count( $alert_states ), '%d' ) );
$status_where = $wpdb->prepare(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- It's ok.
"WHERE alert_state IN ($alert_states_placeholders)",
$alert_states
);
} else {
$status_where = 'WHERE 1=1';
}
$test_run_where = '';
if ( null !== $test_run_id ) {
$test_run_where = $wpdb->prepare(
' AND test_run_id = %d',
$test_run_id
);
}
$where = "{$status_where}{$test_run_where}";
$query = "
SELECT COUNT(*) FROM $alerts_table
$where
";
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- It's prepared above
return (int) $wpdb->get_var( $query );
}
/**
* Get total test items grouped by test run from database
*
* @param int $filter_status_query the id of status.
*
* @return array
*/
public static function get_total_items_grouped_by_test_run( $filter_status_query = null ) {
global $wpdb;
$alerts_table = Alerts_Table::get_table_name();
// 0 = Open
// 1 = Archived.
// 2 = False Positive.
switch ( $filter_status_query ?? null ) {
case 'archived':
$alert_states = [ 1, 2 ];
break;
case 'all':
$alert_states = [];
break;
default:
$alert_states = [ 0 ];
break;
}
if ( ! empty( $alert_states ) ) {
$alert_states_placeholders = implode( ', ', array_fill( 0, count( $alert_states ), '%d' ) );
$status_where = $wpdb->prepare(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- It's ok.
"WHERE alert_state IN ($alert_states_placeholders)",
$alert_states
);
} else {
$status_where = 'WHERE 1=1';
}
$where = "{$status_where} AND test_run_id IS NOT NULL";
$query = "
SELECT COUNT(DISTINCT test_run_id) FROM $alerts_table
$where
";
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- It's prepared above
return (int) $wpdb->get_var( $query );
}
/**
* Update alert status.
*
* @param int $id the id of the item.
* @param int $new_alert_state the new state of the item.
*/
public static function set_alert_state( $id = 0, $new_alert_state = null ) {
// 0 = Unread / 1 = Read
if ( in_array( $new_alert_state, [ 0, 1 ], true ) ) {
global $wpdb;
$alerts_table = Alerts_Table::get_table_name();
$data = [ 'alert_state' => $new_alert_state ];
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- It's ok.
return $wpdb->update( $alerts_table, $data, [ 'id' => $id ] );
} else {
return false;
}
}
/**
* Update all alert statuses for post id.
*
* @param int $post_id the id of the item.
* @param int $new_alert_state the new state of the item.
*/
public static function set_alert_state_for_post_id( $post_id = 0, $new_alert_state = null ) {
// 0 = Unread / 1 = Read
if ( in_array( $new_alert_state, [ 0, 1 ], true ) ) {
global $wpdb;
$alerts_table = Alerts_Table::get_table_name();
$data = [ 'alert_state' => $new_alert_state ];
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- It's ok.
return $wpdb->update( $alerts_table, $data, [ 'post_id' => $post_id ] );
} else {
return false;
}
}
/**
* Get the next open alert id.
*/
public static function get_next_open_alert_id() {
global $wpdb;
$alerts_table = Alerts_Table::get_table_name();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- It's ok.
return $wpdb->get_var(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- It's ok.
$wpdb->prepare( "SELECT id FROM $alerts_table WHERE alert_state = %d LIMIT 1", 0 )
);
}
/**
* Get the next alert id for the pagination.
*
* @param int $id the id of the item.
* @param array $test_run_id the id of the test run.
*/
public static function get_pagination_next_alert_id( $id = 0, $test_run_id = 0 ) {
global $wpdb;
$alerts_table = Alerts_Table::get_table_name();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- It's ok.
return (int) $wpdb->get_var(
$wpdb->prepare(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- It's ok.
"SELECT id FROM $alerts_table
WHERE id > %d
AND test_run_id = %d
ORDER BY id ASC
LIMIT 1",
$id,
$test_run_id
)
);
}
/**
* Get the prev alert id for the pagination.
*
* @param int $id the id of the item.
* @param array $test_run_id the id of the test run.
*/
public static function get_pagination_prev_alert_id( $id = 0, $test_run_id = 0 ) {
global $wpdb;
$alerts_table = Alerts_Table::get_table_name();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- It's ok.
return (int) $wpdb->get_var(
$wpdb->prepare(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- It's ok.
"SELECT id FROM $alerts_table
WHERE id < %d
AND test_run_id = %d
ORDER BY id DESC
LIMIT 1",
$id,
$test_run_id
)
);
}
/**
* Get the current position inside the pagination.
*
* @param int $id the id of the item.
* @param array $test_run_id the id of the test run.
*/
public static function get_pagination_current_position( $id = 0, $test_run_id = 0 ) {
global $wpdb;
$alerts_table = Alerts_Table::get_table_name();
// Let's get the count of the alerts that are before the current one.
$query = $wpdb->prepare(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- It's ok.
"SELECT count(*) FROM `$alerts_table`
WHERE id < %d
AND test_run_id = %d
ORDER BY id DESC",
$id,
$test_run_id
);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- It's ok.
$count = (int) $wpdb->get_var( $query );
// We need to add 1. E.g. if the count is 0, the position is 1.
return $count + 1;
}
/**
* Delete a test from database and update its post meta.
*
* @param int $id the id of the item.
*
* @return array
*/
public static function delete( $id = 0 ) {
global $wpdb;
$alerts_table = Alerts_Table::get_table_name();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- It's ok.
return $wpdb->delete( $alerts_table, [ 'id' => $id ] );
}
/**
* Get the alert state.
*
* @param object $alert the alert object.
*/
public static function is_archived( $alert ) {
// 0 = Open
// 1 = Archived.
// 2 = False Positive.
//phpcs:ignore WordPress.PHP.StrictInArray.FoundNonStrictFalse -- It's ok.
return in_array( $alert->alert_state, [ 1, 2 ], false );
}
/**
* Get unread alerts count by test run ids.
*
* @param array|int $test_run_ids the ids of the test runs.
*/
public static function get_unread_count_by_test_run_ids( $test_run_ids ) {
global $wpdb;
if ( empty( $test_run_ids ) ) {
return [];
}
if ( is_int( $test_run_ids ) || is_string( $test_run_ids ) ) {
$test_run_ids = [ $test_run_ids ];
}
$alerts_table = Alerts_Table::get_table_name();
$placeholders = implode( ', ', array_fill( 0, count( $test_run_ids ), '%d' ) );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- It's ok.
return $wpdb->get_results(
$wpdb->prepare(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- It's ok.
"SELECT test_run_id, COUNT(*) as count FROM $alerts_table WHERE test_run_id IN ($placeholders) AND alert_state = 0 GROUP BY test_run_id",
$test_run_ids
)
);
}
/**
* Set read status by test run.
*
* @param int $test_run_id the id of the test run.
* @param int $read_status the state of the item.
*/
public static function set_read_status_by_test_run( $test_run_id, $read_status = 1 ) {
global $wpdb;
$alerts_table = Alerts_Table::get_table_name();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- It's ok.
return $wpdb->update(
$alerts_table,
[ 'alert_state' => $read_status ],
[ 'test_run_id' => intval( $test_run_id ) ]
);
}
/**
* Set or remove alert as false positive.
*
* @param int $alert_id the id of the item.
* @param int $is_false_positive the state of the item.
*/
public static function set_false_positive( $alert_id, $is_false_positive = 1 ) {
global $wpdb;
$alerts_table = Alerts_Table::get_table_name();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- It's ok.
return $wpdb->update(
$alerts_table,
[ 'is_false_positive' => $is_false_positive ],
[ 'id' => intval( $alert_id ) ]
);
}
}
@@ -0,0 +1,630 @@
<?php
namespace Vrts\Models;
use Vrts\Core\Utilities\Date_Time_Helpers;
use Vrts\Core\Utilities\Url_Helpers;
use Vrts\Features\Service;
use Vrts\Features\Subscription;
use Vrts\Tables\Alerts_Table;
use Vrts\Tables\Test_Runs_Table;
/**
* Model Tests Page.
*/
class Test_Run {
/**
* Get all test items from database
*
* @param array $args Optional.
* @param bool $return_count Optional.
*
* @return object
*/
public static function get_items( $args = [], $return_count = false ) {
global $wpdb;
$test_runs_table = Test_Runs_Table::get_table_name();
$alerts_table = Alerts_Table::get_table_name();
$defaults = [
'number' => 20,
'offset' => 0,
'orderby' => 'id',
'order' => 'DESC',
];
$args = wp_parse_args( $args, $defaults );
$select = $return_count ? 'SELECT COUNT(*)' : 'SELECT *';
$where = 'WHERE started_at IS NOT NULL AND finished_at IS NOT NULL';
if ( isset( $args['filter_status'] ) && null !== $args['filter_status'] ) {
if ( 'changes-detected' === $args['filter_status'] ) {
$where .= ' AND alerts_count > 0';
}
}
$whitelist_orderby = [ 'id', 'title', 'started_at', 'finished_at', 'trigger' ];
$whitelist_order = [ 'ASC', 'DESC' ];
$orderby = in_array( $args['orderby'], $whitelist_orderby, true ) ? $args['orderby'] : 'id';
$order = in_array( $args['order'], $whitelist_order, true ) ? $args['order'] : 'DESC';
if ( 'status' === $orderby ) {
$orderby = "ORDER BY calculated_status $order, calculated_date $order";
} else {
$orderby = "ORDER BY `$orderby` $order";
}
$limit = $args['number'] > 100 ? 100 : $args['number'];
if ( $args['number'] < 1 ) {
$limits = '';
} else {
$limits = $wpdb->prepare(
'LIMIT %d, %d',
$args['offset'],
$limit
);
}
$run_title = $wpdb->prepare(
'CONCAT( %s, runs.id ) as title',
esc_html__( 'Run #', 'visual-regression-tests' )
);
$query = "
$select
FROM (
SELECT
runs.id,
$run_title,
runs.tests,
SUM( IF( alerts.id IS NOT NULL, 1, 0 ) ) as alerts_count,
SUM( IF( alerts.id IS NOT NULL and alerts.alert_state = 0, 1, 0 ) ) as unread_alerts_count,
runs.trigger,
runs.trigger_notes,
runs.trigger_meta,
runs.started_at,
runs.scheduled_at,
runs.finished_at
FROM $test_runs_table as runs
LEFT JOIN $alerts_table as alerts
ON runs.id = alerts.test_run_id
GROUP BY runs.id
) runs
$where
$orderby
$limits
";
if ( $return_count ) {
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Query is prepared above.
$items = $wpdb->get_var( $query );
} else {
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Query is prepared above.
$items = $wpdb->get_results( $query );
}
return $items;
}
/**
* Get all queued test items from database
*
* @return object
*/
public static function get_queued_items() {
global $wpdb;
$test_runs_table = Test_Runs_Table::get_table_name();
$run_title = $wpdb->prepare(
'CONCAT( %s, id ) as title',
esc_html__( 'Run #', 'visual-regression-tests' )
);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- It's ok.
return $wpdb->get_results(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- It's ok.
"SELECT *, $run_title FROM $test_runs_table WHERE finished_at is NULL ORDER BY scheduled_at ASC"
);
}
/**
* Get a single test from database
*
* @param int $id the id of the item.
*
* @return object
*/
public static function get_item( $id = 0 ) {
global $wpdb;
$test_runs_table = Test_Runs_Table::get_table_name();
$run_title = $wpdb->prepare(
'CONCAT( %s, id ) as title',
esc_html__( 'Run #', 'visual-regression-tests' )
);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- It's ok.
return $wpdb->get_row(
$wpdb->prepare(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- It's ok.
"SELECT *, $run_title FROM $test_runs_table WHERE id = %d",
$id
)
);
}
/**
* Get multiple tests from database by id
*
* @param array $ids the ids of the items.
*
* @return object
*/
public static function get_items_by_ids( $ids = [] ) {
global $wpdb;
$test_runs_table = Test_Runs_Table::get_table_name();
$run_title = $wpdb->prepare(
'CONCAT( %s, id ) as title',
esc_html__( 'Run #', 'visual-regression-tests' )
);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- It's ok.
return $wpdb->get_results(
$wpdb->prepare(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- It's ok.
"SELECT *, $run_title FROM $test_runs_table WHERE id IN (" . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ')',
$ids
)
);
}
/**
* Get test run by service test id
*
* @param int $test_run_id Service test run id.
*
* @return object
*/
public static function get_by_service_test_run_id( $test_run_id ) {
global $wpdb;
$test_runs_table = Test_Runs_Table::get_table_name();
$run_title = $wpdb->prepare(
'CONCAT( %s, id ) as title',
esc_html__( 'Run #', 'visual-regression-tests' )
);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- It's ok.
return $wpdb->get_row(
$wpdb->prepare(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- It's ok.
"SELECT *, $run_title FROM $test_runs_table WHERE service_test_run_id = %s",
$test_run_id
)
);
}
/**
* Get total test items from database
*
* @param int $filter_status_query the id of status.
*
* @return array
*/
public static function get_total_items( $filter_status_query = null ) {
return (int) self::get_items( [
'number' => -1,
'filter_status' => $filter_status_query,
], true );
}
/**
* Insert or update test data
*
* @param array $args The arguments to insert.
* @param int $row_id The row id to update.
*
* @return int|void
*/
public static function save( $args = [], $row_id = null ) {
global $wpdb;
$test_runs_table = Test_Runs_Table::get_table_name();
if ( ! $row_id ) {
// Insert a new row.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- It's ok.
if ( $wpdb->insert( $test_runs_table, $args ) ) {
return $wpdb->insert_id;
}
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- It's ok.
} elseif ( $wpdb->update( $test_runs_table, $args, [ 'id' => $row_id ] ) ) {
return $row_id;
}
}
/**
* Delete duplicate test runs by service_test_run_id from database.
*
* @return void
*/
public static function delete_duplicates() {
global $wpdb;
$test_runs_table = Test_Runs_Table::get_table_name();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- It's ok.
$wpdb->query(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- It's ok.
"DELETE t1 FROM $test_runs_table t1 INNER JOIN $test_runs_table t2 WHERE t1.id > t2.id AND t1.service_test_run_id = t2.service_test_run_id"
);
}
/**
* Delete empty test runs from database.
*
* @return void
*/
public static function delete_empty() {
global $wpdb;
$test_runs_table = Test_Runs_Table::get_table_name();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- It's ok.
$wpdb->query(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- It's ok.
"DELETE FROM $test_runs_table WHERE service_test_run_id IS NULL"
);
}
/**
* Insert multiple test data
*
* @param array $data Data to update (in multi array column => value pairs).
*
* @return int|false The number of rows affected, or false on error.
*/
public static function save_multiple( $data = [] ) {
global $wpdb;
$test_runs_table = Test_Runs_Table::get_table_name();
// If there is no data, return false.
if ( ! isset( $data[0] ) ) {
return false;
}
$fields = '`' . implode( '`, `', array_keys( $data[0] ) ) . '`';
$formats = implode( ', ', array_map(function ( $row ) {
return '(' . implode( ', ', array_fill( 0, count( $row ), '%s' ) ) . ')';
}, $data ) );
$values = [];
foreach ( $data as $row ) {
foreach ( $row as $value ) {
$values[] = $value;
}
}
// TODO: add support for update.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- It's ok.
return $wpdb->query(
$wpdb->prepare(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- It's ok.
"INSERT INTO `$test_runs_table` ($fields) VALUES $formats",
$values
)
);
}
/**
* Get test run trigger title
*
* @param int|object $test_run test run id or test object.
*
* @return string
*/
public static function get_trigger_title( $test_run ) {
if ( is_int( $test_run ) ) {
$test_run = self::get_item( $test_run );
}
$trigger_titles = [
'manual' => __( 'Manual', 'visual-regression-tests' ),
'scheduled' => __( 'Schedule', 'visual-regression-tests' ),
'api' => __( 'API', 'visual-regression-tests' ),
'update' => __( 'Update', 'visual-regression-tests' ),
'legacy' => __( 'Legacy', 'visual-regression-tests' ),
];
return $trigger_titles[ $test_run->trigger ] ?? __( 'Unknown', 'visual-regression-tests' );
}
/**
* Get test run trigger text color
*
* @param int|object $test_run test run id or test object.
*
* @return string
*/
public static function get_trigger_text_color( $test_run ) {
if ( is_int( $test_run ) ) {
$test_run = self::get_item( $test_run );
}
$trigger_colours = [
'manual' => '#045495',
'scheduled' => '#591b98',
'api' => '#ae4204',
'update' => '#a51d7f',
];
return $trigger_colours[ $test_run->trigger ] ?? '#2c3338';
}
/**
* Get test run trigger background color
*
* @param int|object $test_run test run id or test object.
*
* @return string
*/
public static function get_trigger_background_color( $test_run ) {
if ( is_int( $test_run ) ) {
$test_run = self::get_item( $test_run );
}
$trigger_colours = [
'manual' => 'rgba(5, 116, 206, 0.1)',
'scheduled' => 'rgba(106, 26, 185, 0.1)',
'api' => 'rgba(224, 84, 6, 0.15)',
'update' => 'rgba(200, 11, 147, 0.1)',
];
return $trigger_colours[ $test_run->trigger ] ?? 'rgba(192, 192, 192, 0.15)';
}
/**
* Get test run calculated status
*
* @param int|object $test_run test run id or test object.
*
* @return string
*/
public static function get_calculated_status( $test_run ) {
if ( is_int( $test_run ) ) {
$test_run = self::get_item( $test_run );
}
$has_alerts = ( $test_run->alerts_count ?? 0 ) > 0;
if ( $has_alerts ) {
return 'has-alerts';
}
if ( ! empty( $test_run->started_at ) && empty( $test_run->finished_at ) ) {
return 'running';
}
if ( ! empty( $test_run->scheduled_at ) && empty( $test_run->finished_at ) ) {
return 'scheduled';
}
return 'passed';
}
/**
* Delete a test from database.
*
* @param int $test_id the id of the item.
*
* @return int
*/
public static function delete( $test_id = 0 ) {
global $wpdb;
$test_runs_table = Test_Runs_Table::get_table_name();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- It's ok.
return $wpdb->delete( $test_runs_table, [ 'id' => $test_id ] );
}
/**
* Delete all not finished test runs from database.
*
* @return int
*/
public static function delete_all_not_finished() {
global $wpdb;
$test_runs_table = Test_Runs_Table::get_table_name();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- It's ok.
return $wpdb->query( "DELETE FROM $test_runs_table WHERE finished_at IS NULL" );
}
/**
* Convert values to correct type.
*
* @param object $test_run The test run object.
*
* @return object
*/
public static function cast_values( $test_run ) {
$test_run->id = ! is_null( $test_run->id ) ? (int) $test_run->id : null;
$test_run->tests = ! is_null( $test_run->tests ) ? maybe_unserialize( $test_run->tests ) : [];
$test_run->alerts = ! is_null( $test_run->alerts ) ? maybe_unserialize( $test_run->alerts ) : [];
$test_run->started_at = ! is_null( $test_run->started_at ) ? mysql2date( 'c', $test_run->started_at ) : null;
$test_run->scheduled_at = ! is_null( $test_run->scheduled_at ) ? mysql2date( 'c', $test_run->scheduled_at ) : null;
$test_run->finished_at = ! is_null( $test_run->finished_at ) ? mysql2date( 'c', $test_run->finished_at ) : null;
return $test_run;
}
/**
* Get test run status data
*
* @param int|object $test_run test run id or test run object.
*
* @return array
*/
public static function get_status_data( $test_run ) {
if ( is_int( $test_run ) ) {
$test_run = self::get_item( $test_run );
}
$test_run_status = self::get_calculated_status( $test_run );
$instructions = '';
switch ( $test_run_status ) {
case 'has-alerts':
$alerts_count = $test_run->alerts_count;
$class = 'paused';
$text = esc_html__( 'Changes detected', 'visual-regression-tests' );
$instructions = Date_Time_Helpers::get_formatted_relative_date_time( $test_run->finished_at );
$instructions .= sprintf(
'<a href="%1$s" class="vrts-test-run-view-alerts"><i class="dashicons dashicons-image-flip-horizontal"></i> %2$s</a>',
esc_url( Url_Helpers::get_alerts_page( $test_run ) ),
sprintf(
// translators: %s: number of alerts.
esc_html( _n( 'View Alert (%s)', 'View Alerts (%s)', $alerts_count, 'visual-regression-tests' ) ),
$alerts_count
)
);
break;
case 'running':
$class = 'waiting';
$text = '';
$instructions = sprintf(
'<span>%s</span>',
sprintf(
// translators: %1$s: link start to test runs page. %2$s: link end to test runs page.
wp_kses( __( '%1$sRefresh page%2$s to see results', 'visual-regression-tests' ), [ 'a' => [ 'href' => [] ] ] ),
'<a href="' . esc_url( Url_Helpers::get_page_url( 'runs' ) ) . '">',
'</a>'
)
);
break;
case 'scheduled':
$class = 'waiting';
$text = esc_html__( 'Pending', 'visual-regression-tests' );
$instructions .= sprintf(
'<a href="%1$s">%2$s</a>',
esc_url( Url_Helpers::get_page_url( 'settings' ) ),
esc_html__( 'Edit Test Configuration', 'visual-regression-tests' )
);
break;
case 'passed':
default:
$class = 'running';
$text = esc_html__( 'Passed', 'visual-regression-tests' );
if ( $test_run->finished_at ) {
$instructions .= Date_Time_Helpers::get_formatted_relative_date_time( $test_run->finished_at );
}
$instructions .= esc_html__( 'No changes detected', 'visual-regression-tests' );
break;
}//end switch
return [
'status' => $test_run_status,
'class' => $class,
'text' => $text,
'instructions' => $instructions,
];
}
/**
* Delete test run by service test run id
*
* @param string $test_run_id the service test run id.
*
* @return int
*/
public static function delete_by_service_test_run_id( $test_run_id ) {
global $wpdb;
$test_runs_table = Test_Runs_Table::get_table_name();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- It's ok.
return $wpdb->delete( $test_runs_table, [ 'service_test_run_id' => $test_run_id ] );
}
/**
* Get test run trigger note
*
* @param object $test_run test run object.
*
* @return string
*/
public static function get_trigger_note( $test_run ) {
if ( ( $test_run->trigger ?? null ) === 'update' ) {
$updates = maybe_unserialize( $test_run->trigger_meta ) ?? [];
$updates = array_merge( ...$updates );
$trigger_notes = implode(', ', array_map(function ( $update ) {
if ( 'core' === $update['type'] ) {
$update_name = 'WordPress';
} else {
$update_name = $update['name'] ?? $update['slug'] ?? null;
}
$update_info = $update['version'] ?? $update['language'] ?? null;
return $update_name . ( empty( $update_info ) ? '' : ' (' . $update_info . ')' );
}, $updates));
return $trigger_notes;
} else {
return $test_run->trigger_notes ?? '';
}
}
/**
* Get next scheduled run
*
* @return object
*/
public static function get_next_scheduled_run() {
global $wpdb;
$test_runs_table = Test_Runs_Table::get_table_name();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- It's ok.
$test_run = $wpdb->get_row(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- It's ok.
"SELECT * FROM $test_runs_table WHERE finished_at IS NULL AND scheduled_at IS NOT NULL ORDER BY scheduled_at ASC LIMIT 1"
);
return $test_run;
}
/**
* Get test run by service test run id
*
* @return mixed
*/
public static function get_stalled_test_run_ids() {
global $wpdb;
$test_runs_table = Test_Runs_Table::get_table_name();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- It's ok.
$test_runs = $wpdb->get_results(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- It's ok.
"SELECT service_test_run_id FROM $test_runs_table
WHERE ( finished_at IS NULL AND started_at IS NULL AND scheduled_at < DATE_SUB( now(), INTERVAL 1 HOUR ) )
OR ( finished_at IS NULL AND started_at IS NOT NULL AND started_at < DATE_SUB( NOW(), INTERVAL 1 HOUR ) )
OR ( finished_at IS NULL AND started_at IS NULL AND scheduled_at IS NULL )
"
);
return $test_runs;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,110 @@
<?php
namespace Vrts\Rest_Api;
use WP_Error;
use WP_REST_Server;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Controller;
use Vrts\Models\Alert;
use Vrts\Features\Service;
class Rest_Alerts_Controller extends WP_REST_Controller {
/**
* Endpoint namespace.
*
* @var string
*/
protected $namespace = 'vrts/v1';
/**
* Route base.
*
* @var string
*/
protected $rest_base = 'alerts';
/**
* Constructor.
*/
public function __construct() {
add_action( 'rest_api_init', [ $this, 'register_routes' ] );
}
/**
* Registers the routes for alerts.
*/
public function register_routes() {
register_rest_route( $this->namespace, '/' . $this->rest_base . '/(?P<id>[\d]+)/false-positive', [
'methods' => [ WP_REST_Server::CREATABLE, WP_REST_Server::DELETABLE ],
'callback' => [ $this, 'false_positive_callback' ],
'permission_callback' => [ $this, 'get_items_permissions_check' ],
] );
register_rest_route( $this->namespace, '/' . $this->rest_base . '/(?P<id>[\d]+)/read-status', [
'methods' => [ WP_REST_Server::CREATABLE, WP_REST_Server::DELETABLE ],
'callback' => [ $this, 'read_status_callback' ],
'permission_callback' => [ $this, 'get_items_permissions_check' ],
] );
}
/**
* Checks if a given request has access to get items.
*
* @param WP_REST_Request $request Request.
*/
public function get_items_permissions_check( $request ) {
return current_user_can( 'manage_options' );
}
/**
* Deletes a false positive.
*
* @param WP_REST_Request $request Current request.
*/
public function false_positive_callback( WP_REST_Request $request ) {
$id = $request->get_param( 'id' );
$alert = Alert::get_item( $id );
if ( ! $alert ) {
return new WP_Error( 'error', esc_html__( 'Alert not found.', 'visual-regression-tests' ), [ 'status' => 404 ] );
}
$service = new Service();
$should_flag_false_positive = $request->get_method() === WP_REST_Server::CREATABLE;
Alert::set_false_positive( $id, $should_flag_false_positive );
if ( $should_flag_false_positive ) {
$service->mark_alert_as_false_positive( $id );
} else {
$service->unmark_alert_as_false_positive( $id );
}
return new WP_REST_Response( true, 200 );
}
/**
* Read status.
*
* @param WP_REST_Request $request Current request.
*/
public function read_status_callback( WP_REST_Request $request ) {
$id = $request->get_param( 'id' );
$alert = Alert::get_item( $id );
if ( ! $alert ) {
return new WP_Error( 'error', esc_html__( 'Alert not found.', 'visual-regression-tests' ), [ 'status' => 404 ] );
}
$should_mark_as_read = $request->get_method() === WP_REST_Server::CREATABLE ? 1 : 0;
Alert::set_alert_state( $id, $should_mark_as_read );
return new WP_REST_Response( true, 200 );
}
}
@@ -0,0 +1,260 @@
<?php
namespace Vrts\Rest_Api;
use WP_Error;
use WP_REST_Request;
use Vrts\Features\Subscription;
use Vrts\Models\Test_Run;
use Vrts\Services\Test_Run_Service;
use Vrts\Services\Test_Service;
class Rest_Service_Controller {
/**
* Namespace.
*
* @var string
*/
private $namespace;
/**
* Resource name.
*
* @var string
*/
private $resource_name;
/**
* Constructor.
*/
public function __construct() {
$this->namespace = 'vrts/v1';
$this->resource_name = 'service';
add_action( 'rest_api_init', [ $this, 'register_routes' ] );
add_action( 'wp_ajax_nopriv_vrts_service', [ $this, 'ajax_action' ] );
add_action( 'wp_ajax_priv_vrts_service', [ $this, 'ajax_action' ] );
}
/**
* Register routes.
*/
public function register_routes() {
register_rest_route($this->namespace, $this->resource_name, [
'methods' => [ 'POST' ],
'callback' => [ $this, 'service_callback' ],
'permission_callback' => [ $this, 'get_items_permissions_check' ],
]);
}
/**
* Actions for admin-ajax.php
*/
public function ajax_action() {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- It's ok.
$data = json_decode( wp_unslash( $_REQUEST['data'] ?? '' ), true );
$rest_response = $this->perform_action( $data ?? [] );
// If rest response is WP error, get the status code.
if ( is_wp_error( $rest_response ) ) {
$error_data = $rest_response->get_error_data();
status_header( $error_data['status'] );
wp_send_json( $rest_response->get_error_message() );
} else {
status_header( $rest_response->get_status() );
wp_send_json( $rest_response->get_data() );
}
}
/**
* Gets some data.
*
* @param WP_REST_Request $request Current request.
*/
public function service_callback( WP_REST_Request $request ) {
$data = $request->get_params();
return $this->perform_action( $data );
}
/**
* Perform ajax actions.
*
* @param array $data Current ajax data.
*/
public function perform_action( $data ) {
if ( ! array_key_exists( 'action', $data ) ) {
return new WP_Error( 'error', esc_html__( 'Action parameter is missing.', 'visual-regression-tests' ), [ 'status' => 403 ] );
}
switch ( $data['action'] ) {
case 'test_updated':
$response = $this->test_updated_request( $data );
break;
case 'run_updated':
$response = $this->run_updated_request( $data );
break;
case 'run_deleted':
$response = $this->run_deleted_request( $data );
break;
case 'subscription_changed':
$response = $this->subscription_changed_request();
break;
default:
$response = $this->unknown_action_request();
break;
}//end switch
return $response;
}
/**
* Test updated request
*
* @param array $data Rest api response body.
*/
private function test_updated_request( $data ) {
if ( ! array_key_exists( 'project_id', $data ) ) {
return new WP_Error( 'error', esc_html__( 'Project id is missing.', 'visual-regression-tests' ), [ 'status' => 403 ] );
} elseif ( get_option( 'vrts_project_id' ) !== $data['project_id'] ) {
return new WP_Error( 'error', esc_html__( 'Project id does not match.', 'visual-regression-tests' ), [ 'status' => 403 ] );
} elseif ( ! array_key_exists( 'test_id', $data ) ) {
return new WP_Error( 'error', esc_html__( 'Test id is missing.', 'visual-regression-tests' ), [ 'status' => 403 ] );
}
if ( ! self::verify_signature( $data ) ) {
return new WP_Error( 'error', esc_html__( 'Signature is not valid.', 'visual-regression-tests' ), [ 'status' => 403 ] );
}
$test_service = new Test_Service();
if ( $test_service->update_test_from_api_data( $data ) ) {
Subscription::update_available_tests( $data['remaining_credits'], $data['total_credits'], $data['has_subscription'], $data['tier_id'] );
return rest_ensure_response([
'message' => 'Action test_updated successful.',
]);
}//end if
return new WP_Error( 'error', esc_html__( 'Test not found.', 'visual-regression-tests' ), [ 'status' => 404 ] );
}
/**
* Test Run updated request
*
* @param array $data Rest api response body.
*/
private function run_updated_request( $data ) {
if ( ! array_key_exists( 'project_id', $data ) ) {
return new WP_Error( 'error', esc_html__( 'Project id is missing.', 'visual-regression-tests' ), [ 'status' => 403 ] );
} elseif ( get_option( 'vrts_project_id' ) !== $data['project_id'] ) {
return new WP_Error( 'error', esc_html__( 'Project id does not match.', 'visual-regression-tests' ), [ 'status' => 403 ] );
} elseif ( ! array_key_exists( 'run_id', $data ) ) {
return new WP_Error( 'error', esc_html__( 'Run id is missing.', 'visual-regression-tests' ), [ 'status' => 403 ] );
}
if ( ! self::verify_signature( $data ) ) {
return new WP_Error( 'error', esc_html__( 'Signature is not valid.', 'visual-regression-tests' ), [ 'status' => 403 ] );
}
$test_run_service = new Test_Run_Service();
if ( $test_run_service->update_run_from_api_data( $data ) ) {
Subscription::update_available_tests( $data['remaining_credits'], $data['total_credits'], $data['has_subscription'], $data['tier_id'] );
return rest_ensure_response([
'message' => 'Action run_updated successful.',
]);
}//end if
return new WP_Error( 'error', esc_html__( 'Test not found.', 'visual-regression-tests' ), [ 'status' => 404 ] );
}
/**
* Test Run updated request
*
* @param array $data Rest api response body.
*/
private function run_deleted_request( $data ) {
if ( ! array_key_exists( 'project_id', $data ) ) {
return new WP_Error( 'error', esc_html__( 'Project id is missing.', 'visual-regression-tests' ), [ 'status' => 403 ] );
} elseif ( get_option( 'vrts_project_id' ) !== $data['project_id'] ) {
return new WP_Error( 'error', esc_html__( 'Project id does not match.', 'visual-regression-tests' ), [ 'status' => 403 ] );
} elseif ( ! array_key_exists( 'run_id', $data ) ) {
return new WP_Error( 'error', esc_html__( 'Run id is missing.', 'visual-regression-tests' ), [ 'status' => 403 ] );
}
if ( ! self::verify_signature( $data ) ) {
return new WP_Error( 'error', esc_html__( 'Signature is not valid.', 'visual-regression-tests' ), [ 'status' => 403 ] );
}
$test_run_service = new Test_Run_Service();
if ( Test_Run::delete_by_service_test_run_id( $data['run_id'] ) ) {
return rest_ensure_response([
'message' => 'Action run_deleted successful.',
]);
}//end if
return new WP_Error( 'error', esc_html__( 'Test not found.', 'visual-regression-tests' ), [ 'status' => 404 ] );
}
/**
* Verify signature
*
* @param array $data Rest api response body.
*
* @return bool
*/
private function verify_signature( $data ) {
$signature = $data['signature'];
unset( $data['signature'] );
$secret = get_option( 'vrts_project_secret' ) || 'verysecret';
return hash_equals( $signature, hash_hmac( 'sha256', wp_json_encode( $data ), $secret ) );
}
/**
* Subscription changed request
*/
private function subscription_changed_request() {
// When notified about subscription change from service, update the tests with the new status.
Subscription::get_latest_status();
return rest_ensure_response([
'message' => esc_html__( 'Subscription changed action was successful.', 'visual-regression-tests' ),
]);
}
/**
* Unknown action request
*/
private function unknown_action_request() {
return new WP_Error( 'error', esc_html__( 'Unknown action.', 'visual-regression-tests' ), [ 'status' => 403 ] );
}
/**
* Check permissions for the requets.
*/
public function get_items_permissions_check() {
return true;
}
/**
* Sets up the proper HTTP status code for authorization.
*/
public function authorization_status_code() {
$status = 401;
if ( is_user_logged_in() ) {
$status = 403;
}
return $status;
}
}
@@ -0,0 +1,76 @@
<?php
namespace Vrts\Rest_Api;
use WP_Error;
use WP_REST_Server;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Controller;
use Vrts\Models\Alert;
use Vrts\Models\Test_Run;
class Rest_Test_Runs_Controller extends WP_REST_Controller {
/**
* Endpoint namespace.
*
* @var string
*/
protected $namespace = 'vrts/v1';
/**
* Route base.
*
* @var string
*/
protected $rest_base = 'test-runs';
/**
* Constructor.
*/
public function __construct() {
add_action( 'rest_api_init', [ $this, 'register_routes' ] );
}
/**
* Registers the routes for alerts.
*/
public function register_routes() {
register_rest_route( $this->namespace, '/' . $this->rest_base . '/(?P<id>[\d]+)/read-status', [
'methods' => [ WP_REST_Server::CREATABLE, WP_REST_Server::DELETABLE ],
'callback' => [ $this, 'read_status_callback' ],
'permission_callback' => [ $this, 'get_items_permissions_check' ],
] );
}
/**
* Checks if a given request has access to get items.
*
* @param WP_REST_Request $request Request.
*/
public function get_items_permissions_check( $request ) {
return current_user_can( 'manage_options' );
}
/**
* Read status.
*
* @param WP_REST_Request $request Current request.
*/
public function read_status_callback( WP_REST_Request $request ) {
$id = $request->get_param( 'id' );
$test_run = Test_Run::get_item( $id );
if ( ! $test_run ) {
return new WP_Error( 'error', esc_html__( 'Run not found.', 'visual-regression-tests' ), [ 'status' => 404 ] );
}
$should_mark_as_read = $request->get_method() === WP_REST_Server::CREATABLE ? 1 : 0;
Alert::set_read_status_by_test_run( $id, $should_mark_as_read );
return new WP_REST_Response( true, 200 );
}
}
@@ -0,0 +1,185 @@
<?php
namespace Vrts\Rest_Api;
use Vrts\Features\Subscription;
use WP_REST_Request;
use WP_Error;
use WP_REST_Server;
use Vrts\Models\Test;
use Vrts\Services\Test_Service;
class Rest_Tests_Controller {
/**
* Namespace.
*
* @var string
*/
private $namespace;
/**
* Resource name.
*
* @var string
*/
private $resource_name;
/**
* Constructor.
*/
public function __construct() {
$this->namespace = 'vrts/v1';
$this->resource_name = 'tests';
add_action( 'rest_api_init', [ $this, 'register_routes' ] );
}
/**
* Register routes.
*/
public function register_routes() {
register_rest_route($this->namespace, $this->resource_name, [
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'tests_remaining_total_callback' ],
'permission_callback' => '__return_true',
]);
register_rest_route($this->namespace, $this->resource_name . '/post/(?P<post_id>\d+)', [
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'tests_callback' ],
'permission_callback' => '__return_true',
]);
register_rest_route($this->namespace, $this->resource_name . '/post/(?P<post_id>\d+)', [
'methods' => WP_REST_Server::CREATABLE,
'callback' => [ $this, 'create_test_callback' ],
'permission_callback' => [ $this, 'user_can_create' ],
]);
register_rest_route($this->namespace, $this->resource_name . '/post/(?P<post_id>\d+)', [
'methods' => WP_REST_Server::DELETABLE,
'callback' => [ $this, 'delete_test_callback' ],
'permission_callback' => [ $this, 'user_can_create' ],
]);
register_rest_route($this->namespace, $this->resource_name . '/post/(?P<post_id>\d+)', [
'methods' => WP_REST_Server::EDITABLE,
'callback' => [ $this, 'update_test_callback' ],
'permission_callback' => [ $this, 'user_can_create' ],
]);
}
/**
* Gets some tests data.
*
* @param WP_REST_Request $request Current request.
*/
public function tests_callback( WP_REST_Request $request ) {
$data = $request->get_params();
$post_id = $data['post_id'] ?? 0;
if ( 0 !== $post_id ) {
$test = Test::get_item_by_post_id( $post_id );
if ( ! empty( $test ) ) {
$test = Test::cast_values( $test );
return rest_ensure_response( $test, 200 );
}
}
$error = new WP_Error(
'rest_not_found',
esc_html__( 'The test does not exist.', 'visual-regression-tests' ),
[ 'status' => 404 ] );
return rest_ensure_response( $error );
}
/**
* Get remaining and total tests.
*/
public function tests_remaining_total_callback() {
return rest_ensure_response([
'remaining_tests' => (int) Subscription::get_remaining_tests(),
'total_tests' => (int) Subscription::get_total_tests(),
], 200);
}
/**
* Creates a test.
*
* @param WP_REST_Request $request Current request.
*/
public function create_test_callback( WP_REST_Request $request ) {
$data = $request->get_params();
$post_id = $data['post_id'] ?? 0;
if ( 0 !== $post_id ) {
$service = new Test_Service();
$test = $service->create_test( $post_id );
return rest_ensure_response( Test::cast_values( $test ), 201 );
}
$error = new WP_Error(
'rest_create_test_failed',
esc_html__( 'The test could not be created.', 'visual-regression-tests' ),
[ 'status' => 400 ] );
return rest_ensure_response( $error );
}
/**
* Deletes a test.
*
* @param WP_REST_Request $request Current request.
*/
public function delete_test_callback( WP_REST_Request $request ) {
$data = $request->get_params();
$post_id = $data['post_id'] ?? 0;
if ( 0 !== $post_id ) {
$test = Test::get_item_by_post_id( $post_id );
$service = new Test_Service();
$service->delete_test( (int) $test->id );
return rest_ensure_response( [], 200 );
}
$error = new WP_Error(
'rest_delete_test_failed',
esc_html__( 'The test could not be deleted.', 'visual-regression-tests' ),
[ 'status' => 400 ] );
return rest_ensure_response( $error );
}
/**
* Updates a test.
*
* @param WP_REST_Request $request Current request.
*/
public function update_test_callback( WP_REST_Request $request ) {
$data = $request->get_params();
$post_id = $data['post_id'] ?? 0;
$test_id = $data['test_id'] ?? 0;
$hide_css_selectors = $data['hide_css_selectors'] ?? [];
if ( 0 !== $post_id && 0 !== $test_id && ! empty( $hide_css_selectors ) ) {
$service = new Test_Service();
$updated = $service->update_css_hide_selectors( $test_id, $hide_css_selectors );
if ( $updated && ! is_wp_error( $updated ) ) {
$service->resume_test( $post_id );
$test = Test::get_item_by_post_id( $post_id );
if ( ! empty( $test ) ) {
return rest_ensure_response( Test::cast_values( $test ), 200 );
}
}
}
$error = new WP_Error(
'rest_update_test_failed',
esc_html__( 'The test could not be updated.', 'visual-regression-tests' ),
[ 'status' => 400 ] );
return rest_ensure_response( $error );
}
/**
* Checks if a given request has access to create items.
*
* @return bool True if the request has access to create items, false otherwise.
*/
public function user_can_create() {
return current_user_can( 'manage_options' );
}
}
@@ -0,0 +1,48 @@
<?php
namespace Vrts\Services;
use Vrts\Tables\Alerts_Table;
class Alert_Service {
/**
* Create alert from comparison.
*
* @param int $post_id Post ID.
* @param int $test_id Test ID.
* @param array $comparison Comparison.
* @param object $test_run Test run.
*/
public function create_alert_from_comparison( $post_id, $test_id, $comparison, $test_run = null ) {
global $wpdb;
$table_alert = Alerts_Table::get_table_name();
$prepare_alert = [];
$prepare_alert['post_id'] = $post_id;
$prepare_alert['screenshot_test_id'] = $test_id;
$prepare_alert['target_screenshot_url'] = $comparison['screenshot']['image_url'];
$prepare_alert['target_screenshot_finish_date'] = $comparison['screenshot']['updated_at'];
$prepare_alert['base_screenshot_url'] = $comparison['base_screenshot']['image_url'];
$prepare_alert['base_screenshot_finish_date'] = $comparison['base_screenshot']['updated_at'];
$prepare_alert['comparison_screenshot_url'] = $comparison['image_url'];
$prepare_alert['comparison_id'] = $comparison['id'];
$prepare_alert['differences'] = $comparison['pixels_diff'];
$prepare_alert['test_run_id'] = $test_run ? $test_run->id : null;
$prepare_alert['meta'] = maybe_serialize( $comparison['meta'] ?? [] );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- It's ok.
if ( $wpdb->insert( $table_alert, $prepare_alert ) ) {
$alert_id = $wpdb->insert_id;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- It's ok.
$wpdb->update(
$table_alert,
[ 'title' => '#' . $alert_id ],
[ 'id' => $alert_id ]
);
}
return $alert_id;
}
}
@@ -0,0 +1,120 @@
<?php
namespace Vrts\Services;
use Vrts\Models\Alert;
use Vrts\Models\Test;
use Vrts\Models\Test_Run;
class Email_Service {
/**
* Send Test Run email.
*
* @param int $test_run_id the id of the test run.
*
* @return bool
*/
public function send_test_run_email( $test_run_id ) {
$subject = sprintf(
/* translators: %1$s: the id of the test run, %2$s: home url */
esc_html_x( 'VRTs: Run #%1$s (%2$s)', 'test run notification email subject', 'visual-regression-tests' ),
$test_run_id,
esc_url( home_url() )
);
$data = $this->get_test_run_email_data( $test_run_id );
$message = vrts()->get_component( 'emails/test-run', $data );
$headers = [
'Content-Type: text/html; charset=UTF-8',
];
$emails = $this->get_test_run_emails( $data['run'] );
if ( $emails ) {
remove_filter( 'wp_mail', 'wp_staticize_emoji_for_email' );
return wp_mail( $emails, wp_specialchars_decode( $subject ), $message, $headers );
}
return false;
}
/**
* Get the emails for the test run.
*
* @param object $run the test run.
*
* @return array Email addresses.
*/
private function get_test_run_emails( $run ) {
$emails = [];
$trigger = $run->trigger;
switch ( $trigger ) {
case 'manual':
$meta = maybe_unserialize( $run->trigger_meta );
$user = get_user_by( 'ID', $meta['user_id'] ?? 0 );
if ( $user ) {
$emails = [ $user->user_email ];
}
break;
case 'api':
$emails = vrts()->settings()->get_option( 'vrts_email_api_notification_address' );
break;
case 'update':
$emails = vrts()->settings()->get_option( 'vrts_email_update_notification_address' );
break;
default:
$emails = vrts()->settings()->get_option( 'vrts_email_notification_address' );
break;
}
return $emails;
}
/**
* Get the data for the test run email.
*
* @param int $run_id the id of the test run.
*/
private function get_test_run_email_data( $run_id ) {
$run = Test_Run::get_item( $run_id );
$tests = maybe_unserialize( $run->tests );
$alerts = Alert::get_items_by_test_run( $run_id );
if ( is_array( $tests ) && count( $tests ) > 0 && ! is_array( $tests[0] ) ) {
$tests = array_map( function ( $test ) {
$test = (int) $test;
$post_id = Test::get_post_id( $test );
return [
'id' => $test,
'post_id' => $post_id,
'post_title' => get_the_title( $post_id ),
'permalink' => get_permalink( $post_id ),
];
}, $tests );
}
usort( $tests, function ( $a, $b ) {
return $a['post_id'] > $b['post_id'] ? -1 : 1;
} );
$data = [
'run' => $run,
'tests' => $tests,
'alerts' => $alerts,
];
return $data;
}
/**
* Preview test run email.
*
* @param int $run_id the id of the test run.
*/
public function preview_test_run_email( $run_id ) {
$data = $this->get_test_run_email_data( $run_id );
vrts()->component( 'emails/test-run', $data );
}
}
@@ -0,0 +1,79 @@
<?php
namespace Vrts\Services;
use Vrts\Features\Cron_Jobs;
use Vrts\Features\Service;
use Vrts\Features\Subscription;
use Vrts\Models\Test;
class Manual_Test_Service {
const OPTION_NAME_STATUS = 'vrts_run_manual_test_is_active';
/**
* Check whether the option is set to true.
*
* @return bool
*/
public function get_option() {
return get_option( self::OPTION_NAME_STATUS );
}
/**
* Sets the option.
*
* @param int $status Status 1 for success, 2 for failure.
*/
public function set_option( $status = 1 ) {
update_option( self::OPTION_NAME_STATUS, $status );
}
/**
* Delete the option.
*/
public function delete_option() {
delete_option( self::OPTION_NAME_STATUS );
}
/**
* Run manual tests.
*
* @param array|null $test_ids Array of test ids.
*/
public function run_tests( $test_ids = null ) {
$has_subscription = Subscription::get_subscription_status();
if ( ! $has_subscription ) {
return false;
}
if ( empty( $test_ids ) ) {
$tests = Test::get_all_running();
} else {
$tests = Test::get_items_by_ids( $test_ids );
}
$service_test_ids = array_map( function ( $test ) {
return $test->service_test_id;
}, $tests );
self::set_option();
$request = Service::run_manual_tests( $service_test_ids, [
'trigger_meta' => [ 'user_id' => get_current_user_id() ],
] );
$test_ids = array_map( function ( $test ) {
return $test->id;
}, $tests );
if ( 201 === $request['status_code'] ) {
self::set_option( 1 );
$response = $request['response'];
$service = new Test_Run_Service();
$id = $service->create_test_run( $response['id'], [
'tests' => maybe_serialize( $test_ids ),
'trigger' => 'manual',
'started_at' => current_time( 'mysql' ),
] );
Cron_Jobs::schedule_initial_fetch_test_run_updates( $id );
} else {
self::set_option( 2 );
}
}
}
@@ -0,0 +1,169 @@
<?php
namespace Vrts\Services;
use Vrts\Features\Service;
use Vrts\Features\Subscription;
use Vrts\Models\Test;
use Vrts\Models\Test_Run;
use Vrts\Services\Email_Service;
class Test_Run_Service {
/**
* Create test from API data.
*
* @param array $data Data.
* @param bool $with_cleanup With cleanup.
*
* @return boolean
*/
public function update_run_from_api_data( $data, $with_cleanup = true ) {
$run_id = $data['run_id'];
if ( empty( $run_id ) ) {
return false;
}
$test_run = Test_Run::get_by_service_test_run_id( $run_id );
$test_run_just_finished = false;
if ( $test_run && empty( $test_run->finished_at ) && ! empty( $data['finished_at'] ) ) {
$test_run_just_finished = true;
$alert_ids = $this->update_tests_and_create_alerts( $data['comparisons'], $test_run );
}
$test_ids = empty( $data['comparison_schedule_ids'] ) ? [] : array_map(function ( $test ) {
return [
'id' => $test->id,
'post_id' => $test->post_id,
'post_title' => get_the_title( $test->post_id ),
'permalink' => get_permalink( $test->post_id ),
];
}, Test::get_by_service_test_ids( $data['comparison_schedule_ids'] ));
$test_run_id = $this->create_test_run( $data['run_id'], [
'tests' => maybe_serialize( $test_ids ),
'started_at' => $data['started_at'],
'finished_at' => $data['finished_at'],
'scheduled_at' => $data['scheduled_at'],
'trigger' => $data['trigger'],
'trigger_notes' => $data['trigger_notes'],
'trigger_meta' => maybe_serialize( $data['trigger_meta'] ),
], true, $with_cleanup );
if ( $test_run_just_finished && ! empty( $alert_ids ) ) {
$email_service = new Email_Service();
$email_service->send_test_run_email( $test_run_id );
}
return true;
}
/**
* Update tests and create alerts.
*
* @param array $comparisons Comparisons.
* @param object $test_run Test run.
*
* @return array
*/
protected function update_tests_and_create_alerts( $comparisons, $test_run ) {
$alert_ids = [];
foreach ( $comparisons as $comparison ) {
$test_id = $comparison['comparison_schedule_id'];
$alert_id = null;
if ( $comparison['pixels_diff'] > 1 && ! $comparison['matches_false_positive'] ) {
$post_id = Test::get_post_id_by_service_test_id( $test_id );
$alert_service = new Alert_Service();
$alert_id = $alert_service->create_alert_from_comparison( $post_id, $test_id, $comparison, $test_run );
$alert_ids[] = $alert_id;
}//end if
$test_service = new Test_Service();
$test_service->update_test_from_comparison( $alert_id, $test_id, [
'comparison' => $comparison,
] );
}
return $alert_ids;
}
/**
* Create test run.
*
* @param string $service_test_run_id Service test run id.
* @param array $data Data.
* @param bool $update Update.
* @param bool $with_cleanup With cleanup.
*
* @return boolean
*/
public function create_test_run( $service_test_run_id, $data, $update = false, $with_cleanup = true ) {
$test_run = Test_Run::get_by_service_test_run_id( $service_test_run_id );
if ( $test_run && ! $update ) {
return false;
}
$test_run_id = Test_Run::save(array_merge( $data, [
'service_test_run_id' => $service_test_run_id,
]), $test_run->id ?? null);
if ( $with_cleanup ) {
Test_Run::delete_duplicates();
Test_Run::delete_empty();
$this->check_stalled_test_runs();
}
return $test_run_id;
}
/**
* Check stalled test runs.
*
* @return void
*/
public function check_stalled_test_runs() {
$test_run_ids = array_column( Test_Run::get_stalled_test_run_ids(), 'service_test_run_id' );
if ( empty( $test_run_ids ) ) {
return;
}
$response = Service::fetch_test_runs( $test_run_ids );
if ( 200 === $response['status_code'] ) {
$test_runs = $response['response']['data'] ?? [];
foreach ( $test_runs as $test_run ) {
$this->update_run_from_api_data( $test_run, false );
}
$missing_test_run_ids = array_diff( $test_run_ids, array_column( $test_runs, 'run_id' ) );
foreach ( $missing_test_run_ids as $missing_test_run_id ) {
Test_Run::delete_by_service_test_run_id( $missing_test_run_id );
}
}
}
/**
* Fetch and update tests.
*
* @return void
*/
public function fetch_and_update_test_runs() {
$service_request = Service::fetch_updates();
if ( 200 === $service_request['status_code'] ) {
$response = $service_request['response'];
if ( array_key_exists( 'run_updates', $response ) ) {
$updates = $response['run_updates'];
foreach ( $updates as $update ) {
$this->update_run_from_api_data( $update, false );
}
}
if (
array_key_exists( 'remaining_credits', $response )
&& array_key_exists( 'total_credits', $response )
&& array_key_exists( 'has_subscription', $response )
&& array_key_exists( 'tier_id', $response )
) {
Subscription::update_available_tests( $response['remaining_credits'], $response['total_credits'], $response['has_subscription'], $response['tier_id'] );
}
}
}
}
@@ -0,0 +1,471 @@
<?php
namespace Vrts\Services;
use Vrts\Features\Cron_Jobs;
use Vrts\Features\Service;
use Vrts\Features\Subscription;
use Vrts\Models\Alert;
use Vrts\Models\Test;
use Vrts\Tables\Alerts_Table;
use Vrts\Tables\Tests_Table;
use WP_Error;
class Test_Service {
/**
* Update tests from comparison.
*
* @param int $alert_id Alert id.
* @param int $test_id Test id.
* @param array $data Comparison data.
*
* @return int|false
*/
public function update_test_from_comparison( $alert_id, $test_id, $data ) {
global $wpdb;
$table_test = Tests_Table::get_table_name();
$comparison = $data['comparison'];
if ( $comparison['screenshot']['image_url'] ?? null ) {
// Update test row with new id foreign key and add latest screenshot.
$update_data = [
'next_run_date' => $data['next_run_at'] ?? '',
'last_comparison_date' => $comparison['updated_at'],
'base_screenshot_url' => $comparison['screenshot']['image_url'],
'base_screenshot_date' => $comparison['screenshot']['updated_at'],
'is_running' => false,
];
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- It's ok.
return $wpdb->update(
$table_test,
$update_data,
[ 'service_test_id' => $test_id ]
);
}
}
/**
* Update test from schedule.
*
* @param int $test_id Test id.
* @param array $data Screenshot data.
*
* @return void
*/
public function update_test_from_schedule( $test_id, $data ) {
global $wpdb;
$table_test = Tests_Table::get_table_name();
$screenshot = $data['schedule']['base_screenshot'];
if ( $screenshot['image_url'] ?? null ) {
// Update test row with new id foreign key and add latest screenshot.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- It's ok.
$wpdb->update(
$table_test,
[
'base_screenshot_url' => $screenshot['image_url'],
'base_screenshot_date' => $screenshot['updated_at'],
'next_run_date' => $data['next_run_at'] ?? '',
],
[ 'service_test_id' => $test_id ]
);
}
}
/**
* Create test from API data.
*
* @param array $data Data.
*
* @return int|false
*/
public function update_test_from_api_data( $data ) {
$test_id = $data['test_id'];
$post_id = Test::get_post_id_by_service_test_id( $test_id );
if ( $post_id ) {
if ( $data['schedule']['base_screenshot'] ?? null ) {
$this->update_test_from_schedule( $test_id, $data );
} elseif ( $data['comparison'] ?? null ) {
$alert_id = null;
if ( $data['comparison']['pixels_diff'] > 1 && ! $data['matches_false_positive'] ) {
$comparison = $data['comparison'];
$alert_service = new Alert_Service();
$alert_id = $alert_service->create_alert_from_comparison( $post_id, $test_id, $comparison );
}//end if
$test_service = new Test_Service();
$test_service->update_test_from_comparison( $alert_id, $test_id, $data );
}//end if
return true;
}//end if
}
/**
* Fetch and update tests.
*
* @return void
*/
public function fetch_and_update_tests() {
$service_request = Service::fetch_updates();
if ( 200 === $service_request['status_code'] ) {
$response = $service_request['response'];
if ( array_key_exists( 'updates', $response ) ) {
$updates = $response['updates'];
foreach ( $updates as $update ) {
$this->update_test_from_api_data( $update );
}
}
if (
array_key_exists( 'remaining_credits', $response )
&& array_key_exists( 'total_credits', $response )
&& array_key_exists( 'has_subscription', $response )
&& array_key_exists( 'tier_id', $response )
) {
Subscription::update_available_tests( $response['remaining_credits'], $response['total_credits'], $response['has_subscription'], $response['tier_id'] );
}
}
}
/**
* Create test.
*
* @param int $post_id Post id.
*
* @return int|WP_Error
*/
public function create_test( $post_id ) {
if ( Service::is_connected() ) {
$post = get_post( $post_id );
if ( ! $post ) {
return new WP_Error( 'vrts_post_error', __( 'Post not found.', 'visual-regression-tests' ) );
}
$test = Test::get_item_by_post_id( $post_id );
if ( $test ) {
return $test;
}
if ( 'publish' === $post->post_status ) {
return $this->create_remote_test( $post );
} elseif ( 'revision' !== $post->post_type && 'auto-draft' !== $post->post_status ) {
$args = [
'post_id' => $post_id,
'status' => 0,
];
$new_row_id = Test::save( $args );
return Test::get_item( $new_row_id );
}
} else {
return new WP_Error( 'vrts_service_error', __( 'Service is not connected.', 'visual-regression-tests' ) );
}//end if
return new WP_Error( 'vrts_service_error', __( 'Error creating test.', 'visual-regression-tests' ) );
}
/**
* Create tests.
*
* @param array $post_ids Post ids.
*
* @return array|WP_Error
*/
public function create_tests( $post_ids ) {
if ( Service::is_connected() ) {
$created_tests = [];
$post_types = vrts()->get_public_post_types();
$posts = get_posts( [
'post__in' => $post_ids,
'post_type' => $post_types,
'post_status' => 'any',
'posts_per_page' => -1,
'update_post_meta_cache' => false,
'update_post_term_cache' => false,
] );
if ( ! $posts ) {
return new WP_Error( 'vrts_posts_error', __( 'Posts not found.', 'visual-regression-tests' ) );
}
$post_ids = wp_list_pluck( $posts, 'ID' );
$remaining_tests = get_option( 'vrts_remaining_tests' );
$existing_tests = Test::get_items_by_post_ids( $post_ids );
$existing_tests_posts_ids = array_map( 'intval', wp_list_pluck( $existing_tests, 'post_id' ) );
// Remove posts that already have tests.
$posts = array_filter( $posts, function ( $post ) use ( $existing_tests_posts_ids ) {
return ! in_array( $post->ID, $existing_tests_posts_ids, true );
} );
if ( ! $posts ) {
return $existing_tests;
}
// Only try to create as many remote tests as we have remaining.
$published_posts = array_slice( array_filter( $posts, function ( $post ) {
return 'publish' === $post->post_status;
} ), 0, $remaining_tests );
$not_published_posts = array_filter( $posts, function ( $post ) {
return 'publish' !== $post->post_status && 'revision' !== $post->post_type && 'auto-draft' !== $post->post_status;
} );
if ( $published_posts ) {
$published_posts_ids = wp_list_pluck( $published_posts, 'ID' );
$remote_tests = $this->create_remote_tests( $published_posts_ids );
if ( ! is_wp_error( $remote_tests ) ) {
$created_tests = $remote_tests;
}
}
if ( $not_published_posts ) {
$args = array_values( array_map(function ( $post ) {
return [
'post_id' => $post->ID,
'status' => 0,
];
}, $not_published_posts) );
$is_saved = Test::save_multiple( $args );
if ( $is_saved ) {
$not_published_posts_ids = wp_list_pluck( $not_published_posts, 'ID' );
$created_tests = array_merge( $created_tests, Test::get_items_by_post_ids( $not_published_posts_ids ) );
}
}
return $created_tests;
} else {
return new WP_Error( 'vrts_service_error', __( 'Service is not connected.', 'visual-regression-tests' ) );
}//end if
return new WP_Error( 'vrts_service_error', __( 'Error creating test.', 'visual-regression-tests' ) );
}
/**
* Create test remotely via service.
*
* @param WP_Post $post Post.
* @param array $test Test.
*
* @return object|WP_Error
*/
public function create_remote_test( $post, $test = [] ) {
if ( Service::is_connected() ) {
$existing_test = Test::get_item_by_post_id( $post->ID );
if ( $existing_test && $existing_test->service_test_id ) {
return $existing_test;
}
$service_project_id = get_option( 'vrts_project_id' );
$request_url = 'tests';
$parameters = [
'project_id' => $service_project_id,
'url' => get_permalink( $post ),
'frequency' => 'daily',
];
$service_request = Service::rest_service_request( $request_url, $parameters, 'post' );
if ( 201 === $service_request['status_code'] ) {
$test_id = $service_request['response']['id'];
$args = array_merge($test, [
'post_id' => $post->ID,
'service_test_id' => $test_id,
'status' => 1,
]);
unset( $args['id'] );
// TODO: Add some validation.
$new_row_id = Test::save( $args, $test['id'] ?? null );
if ( $new_row_id ) {
Subscription::decrease_tests_count();
Cron_Jobs::schedule_initial_fetch_test_updates( $new_row_id );
return Test::get_item( $new_row_id );
}
} else {
return new WP_Error( 'vrts_service_error', __( 'Service could not create test.', 'visual-regression-tests' ) );
}
} else {
return new WP_Error( 'vrts_service_error', __( 'Service is not connected.', 'visual-regression-tests' ) );
}//end if
}
/**
* Create tests remotely via service.
*
* @param array $post_ids Post IDs.
*
* @return array|WP_Error
*/
public function create_remote_tests( $post_ids ) {
if ( Service::is_connected() ) {
$service_project_id = get_option( 'vrts_project_id' );
$request_url = 'tests';
$urls = array_combine( $post_ids, array_map( function ( $post_id ) {
return get_permalink( $post_id );
}, $post_ids ) );
$parameters = [
'project_id' => $service_project_id,
'urls' => array_values( $urls ),
'frequency' => 'daily',
];
$service_request = Service::rest_service_request( $request_url, $parameters, 'post' );
if ( 201 === $service_request['status_code'] ) {
$args = [];
foreach ( $service_request['response'] as $test ) {
$args[] = [
'post_id' => array_search( $test['url'], $urls, true ),
'service_test_id' => $test['id'],
'status' => 1,
];
}
$saved_tests_number = Test::save_multiple( $args );
if ( $saved_tests_number ) {
Subscription::decrease_tests_count( $saved_tests_number );
$post_ids = wp_list_pluck( $args, 'post_id' );
$created_tests = Test::get_items_by_post_ids( $post_ids );
foreach ( $created_tests as $test ) {
Cron_Jobs::schedule_initial_fetch_test_updates( $test->id );
}
return $created_tests;
} else {
return new WP_Error( 'vrts_test_error', __( 'Plugin could not save tests.', 'visual-regression-tests' ) );
}
} else {
return new WP_Error( 'vrts_service_error', __( 'Service could not create tests.', 'visual-regression-tests' ) );
}//end if
} else {
return new WP_Error( 'vrts_service_error', __( 'Service is not connected.', 'visual-regression-tests' ) );
}//end if
}
/**
* Delete test.
*
* @param int|object $test_id Test id or test object.
*
* @return bool
*/
public function delete_test( $test_id ) {
$delete_locally = true;
$test = ( is_string( $test_id ) || is_int( $test_id ) )
? Test::get_item( (int) $test_id )
: $test_id;
if ( ! empty( $test->service_test_id ) ) {
$delete_locally = (bool) $this->delete_remote_test( $test_id );
}
if ( ! $delete_locally ) {
Subscription::get_latest_status();
}
return $delete_locally && Test::delete( $test->id );
}
/**
* Delete test remotely via service.
*
* @param int|object $test_id Test id or test object.
*
* @return bool
*/
public function delete_remote_test( $test_id ) {
if ( Service::is_connected() ) {
$test = ( is_string( $test_id ) || is_int( $test_id ) )
? Test::get_item( (int) $test_id )
: $test_id;
if (
Service::delete_test( $test->service_test_id )
&& Subscription::increase_tests_count()
) {
$args = (array) $test;
$args['status'] = 0;
$args['service_test_id'] = null;
unset( $args['id'] );
unset( $args['current_alert_id'] );
Test::save( $args, $test->id );
return $test;
} else {
return false;
}
}
}
/**
* Resume stale tests.
*/
public function resume_stale_tests() {
$stale_tests = Test::get_all_inactive();
foreach ( $stale_tests as $stale_test ) {
if ( Subscription::get_remaining_tests() > 0 ) {
$this->resume_stale_test( $stale_test );
}
}
}
/**
* Resume stale test.
*
* @param object $test Test.
*/
private function resume_stale_test( $test ) {
if ( empty( $test->service_test_id ) ) {
$post = get_post( $test->post_id );
if ( 'publish' === $post->post_status ) {
$this->create_remote_test( $post, (array) $test );
}
}
}
/**
* Update test.
*
* @param int $test_id Test id.
* @param string $css_hide_selector CSS selector to hide.
*
* @return int|WP_Error
*/
public function update_css_hide_selectors( $test_id, $css_hide_selector ) {
if ( Service::is_connected() ) {
$test = Test::get_item( $test_id );
if ( ! $test ) {
return new WP_Error( 'vrts_test_error', __( 'Test not found.', 'visual-regression-tests' ) );
}
$updated = Service::update_test(
$test->service_test_id,
[ 'options' => [ 'hideSelectors' => $css_hide_selector ] ]
);
if ( $updated ) {
return Test::save_hide_css_selectors( $test_id, $css_hide_selector );
} else {
return new WP_Error( 'vrts_service_error', __( 'Service could not update test.', 'visual-regression-tests' ) );
}
} else {
return new WP_Error( 'vrts_service_error', __( 'Service is not connected.', 'visual-regression-tests' ) );
}
}
/**
* Resume test.
*
* @param int $post_id Post id.
*/
public function resume_test( $post_id ) {
$test = Test::get_item_by_post_id( $post_id );
if ( ! $test ) {
return false;
} else {
Test::reset_base_screenshot( $test->id );
Service::resume_test( $post_id );
}
}
/**
* Resume tests.
*/
public function resume_tests() {
Test::reset_base_screenshots();
Service::resume_tests();
}
}
@@ -0,0 +1,94 @@
<?php
namespace Vrts\Tables;
class Alerts_Table {
const DB_VERSION = '1.2';
const TABLE_NAME = 'vrts_alerts';
/**
* Get the name of the table.
*/
public static function get_table_name() {
global $wpdb;
return $wpdb->prefix . self::TABLE_NAME;
}
/**
* Create or update the database table for tests.
*/
public static function install_table() {
$option_name = self::TABLE_NAME . '_db_version';
$installed_version = get_option( $option_name );
if ( self::DB_VERSION !== $installed_version ) {
global $wpdb;
$table_name = self::get_table_name();
$charset_collate = $wpdb->get_charset_collate();
if ( $installed_version && version_compare( $installed_version, '1.1', '<' ) ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- It's OK.
$wpdb->query(
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- It's OK.
"ALTER TABLE {$table_name} MODIFY alert_state tinyint NOT NULL DEFAULT 0"
);
}
$sql = "CREATE TABLE {$table_name} (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
title text,
post_id bigint(20),
test_run_id bigint(20),
screenshot_test_id varchar(40),
target_screenshot_url varchar(2048),
target_screenshot_finish_date datetime,
base_screenshot_url varchar(2048),
base_screenshot_finish_date datetime,
comparison_screenshot_url varchar(2048),
comparison_id varchar(40),
differences int(4),
alert_state tinyint NOT NULL DEFAULT 0,
is_false_positive tinyint NOT NULL DEFAULT 0,
meta text,
PRIMARY KEY (id)
) $charset_collate;";
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta( $sql );
if ( $installed_version && version_compare( $installed_version, '1.2', '<' ) ) {
static::set_is_false_positive_from_alert_state();
}
update_option( $option_name, self::DB_VERSION );
}//end if
}
/**
* Drop the database table for tests.
*/
public static function uninstall_table() {
global $wpdb;
$table_name = self::get_table_name();
$sql = "DROP TABLE IF EXISTS {$table_name};";
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.NoCaching -- It's ok.
$wpdb->query( $sql );
delete_option( self::TABLE_NAME . '_db_version' );
}
/**
* Set is_false_positive to 1 for all alerts that have alert_state set to 2.
*/
protected static function set_is_false_positive_from_alert_state() {
global $wpdb;
$table_name = self::get_table_name();
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$wpdb->query(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
"UPDATE {$table_name} SET is_false_positive = 1, alert_state = 1 WHERE alert_state = 2"
);
}
}
@@ -0,0 +1,137 @@
<?php
namespace Vrts\Tables;
class Test_Runs_Table {
const DB_VERSION = '1.1';
const TABLE_NAME = 'vrts_test_runs';
/**
* Get the name of the table.
*/
public static function get_table_name() {
global $wpdb;
return $wpdb->prefix . self::TABLE_NAME;
}
/**
* Create or update the database table for tests.
*/
public static function install_table() {
$option_name = self::TABLE_NAME . '_db_version';
$installed_version = get_option( $option_name );
if ( self::DB_VERSION !== $installed_version ) {
global $wpdb;
$table_name = self::get_table_name();
$charset_collate = $wpdb->get_charset_collate();
$sql = "CREATE TABLE {$table_name} (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
service_test_run_id varchar(40),
tests text,
`trigger` varchar(20),
trigger_notes text,
trigger_meta text default NULL,
started_at datetime,
scheduled_at datetime,
finished_at datetime,
PRIMARY KEY (id)
) $charset_collate;";
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta( $sql );
if ( ! $installed_version ) {
if ( static::create_runs_from_alerts() ) {
update_option( 'vrts_test_runs_has_migrated_alerts', true );
}
}
update_option( $option_name, self::DB_VERSION );
}//end if
}
/**
* Drop the database table for tests.
*/
public static function uninstall_table() {
global $wpdb;
$table_name = self::get_table_name();
$sql = "DROP TABLE IF EXISTS {$table_name};";
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.NoCaching -- It's ok.
$wpdb->query( $sql );
delete_option( self::TABLE_NAME . '_db_version' );
delete_option( 'vrts_test_runs_has_migrated_alerts' );
}
/**
* Migrate alerts from the old table.
*/
public static function create_runs_from_alerts() {
global $wpdb;
$alerts_table = Alerts_Table::get_table_name();
$tests_table = Tests_Table::get_table_name();
$runs_table = self::get_table_name();
$sql = "SELECT
a.id as id,
a.target_screenshot_finish_date as finished_at,
t.id as test_id,
a.post_id as post_id
FROM {$alerts_table} a
JOIN {$tests_table} t
ON t.post_id = a.post_id
WHERE a.test_run_id IS NULL;
";
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
$alerts = $wpdb->get_results( $sql );
if ( empty( $alerts ) ) {
return false;
}
$test_runs = array_map(function ( $alert ) {
return [
'tests' => maybe_serialize( [
[
'id' => $alert->test_id,
'post_id' => $alert->post_id,
'post_title' => get_the_title( $alert->post_id ),
'permalink' => get_permalink( $alert->post_id ),
],
] ),
'alert_id' => $alert->id,
'trigger' => 'legacy',
'started_at' => $alert->finished_at,
'finished_at' => $alert->finished_at,
];
}, $alerts);
$test_runs_values = implode( ',', array_map(function ( $run ) {
return "('" . implode( "','", array_map( 'esc_sql', $run ) ) . "')";
}, $test_runs));
// add alert_id column to test_runs table.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.SchemaChange
$wpdb->query( "ALTER TABLE {$runs_table} ADD COLUMN alert_id bigint(20) unsigned;" );
// insert all test runs with single query.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$wpdb->query( "INSERT INTO {$runs_table} (tests, alert_id, `trigger`, started_at, finished_at) VALUES " . $test_runs_values . ';' );
// update test_run_id in alerts table from newly created test runs based on alert_id column.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$wpdb->query( "UPDATE {$alerts_table} a JOIN {$runs_table} r ON r.alert_id = a.id SET a.test_run_id = r.id;" );
// remove alert_id column from test_runs table.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.SchemaChange
$wpdb->query( "ALTER TABLE {$runs_table} DROP COLUMN alert_id;" );
return true;
}
}
@@ -0,0 +1,84 @@
<?php
namespace Vrts\Tables;
class Tests_Table {
const DB_VERSION = '1.5';
const TABLE_NAME = 'vrts_tests';
/**
* Get the name of the table.
*/
public static function get_table_name() {
global $wpdb;
return $wpdb->prefix . self::TABLE_NAME;
}
/**
* Create or update the database table for tests.
*/
public static function install_table() {
$option_name = self::TABLE_NAME . '_db_version';
$installed_version = get_option( $option_name );
if ( self::DB_VERSION !== $installed_version ) {
global $wpdb;
$table_name = self::get_table_name();
$charset_collate = $wpdb->get_charset_collate();
if ( $installed_version && version_compare( $installed_version, '1.3', '<' ) ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- It's OK.
$wpdb->query(
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- It's OK.
"ALTER TABLE {$table_name} RENAME COLUMN target_screenshot_url TO base_screenshot_url;"
);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- It's OK.
$wpdb->query(
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- It's OK.
"ALTER TABLE {$table_name} RENAME COLUMN snapshot_date TO base_screenshot_date;"
);
}
if ( $installed_version && version_compare( $installed_version, '1.5', '<' ) ) {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- It's OK.
$wpdb->query(
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- It's OK.
"ALTER TABLE {$table_name} DROP COLUMN current_alert_id;"
);
}
$sql = "CREATE TABLE {$table_name} (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
status boolean NOT NULL,
post_id bigint(20),
service_test_id varchar(40),
base_screenshot_url varchar(2048),
base_screenshot_date datetime,
last_comparison_date datetime,
next_run_date datetime,
is_running boolean,
hide_css_selectors longtext,
PRIMARY KEY (id)
) $charset_collate;";
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta( $sql );
update_option( $option_name, self::DB_VERSION );
}//end if
}
/**
* Drop the database table for tests.
*/
public static function uninstall_table() {
global $wpdb;
$table_name = self::get_table_name();
$sql = "DROP TABLE IF EXISTS {$table_name};";
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.NoCaching -- It's ok.
$wpdb->query( $sql );
delete_option( self::TABLE_NAME . '_db_version' );
}
}