diff --git a/.github/workflows/cs-lint.yml b/.github/workflows/cs-lint.yml index fe4dac4..68536e8 100644 --- a/.github/workflows/cs-lint.yml +++ b/.github/workflows/cs-lint.yml @@ -62,9 +62,9 @@ jobs: xml-schema-file: ./vendor/phpunit/phpunit/phpunit.xsd # Check the code-style consistency of the PHP files. -# - name: Check PHP code style -# continue-on-error: true -# run: vendor/bin/phpcs --report-full --report-checkstyle=./phpcs-report.xml + - name: Check PHP code style + continue-on-error: true + run: vendor/bin/phpcs --report-full --report-checkstyle=./phpcs-report.xml -# - name: Show PHPCS results in PR -# run: cs2pr ./phpcs-report.xml + - name: Show PHPCS results in PR + run: cs2pr ./phpcs-report.xml diff --git a/.github/workflows/integrations.yml b/.github/workflows/integrations.yml deleted file mode 100644 index 8f5577e..0000000 --- a/.github/workflows/integrations.yml +++ /dev/null @@ -1,85 +0,0 @@ -name: Run PHPUnit - -on: - # Run on all pushes and on all pull requests. - # Prevent the "push" build from running when there are only irrelevant changes. - push: - paths-ignore: - - "**.md" - pull_request: - # Allow manually triggering the workflow. - workflow_dispatch: - -jobs: - test: - name: WP ${{ matrix.wordpress }} on PHP ${{ matrix.php }} - # Ubuntu-20.x includes MySQL 8.0, which causes `caching_sha2_password` issues with PHP < 7.4 - # https://www.php.net/manual/en/mysqli.requirements.php - # TODO: change to ubuntu-latest when we no longer support PHP < 7.4 - runs-on: ubuntu-18.04 - - env: - WP_VERSION: ${{ matrix.wordpress }} - - strategy: - matrix: - wordpress: ["5.5", "5.6", "5.7"] - php: ["5.6", "7.0", "7.1", "7.2", "7.3", "7.4"] - include: - - php: "8.0" - # Ignore platform requirements, so that PHPUnit 7.5 can be installed on PHP 8.0 (and above). - composer-options: "--ignore-platform-reqs" - extensions: pcov - ini-values: pcov.directory=., "pcov.exclude=\"~(vendor|tests)~\"" - coverage: pcov - exclude: - - php: "8.0" - wordpress: "5.5" - fail-fast: false - - steps: - - name: Checkout code - uses: actions/checkout@v2 - - - name: Setup PHP ${{ matrix.php }} - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php }} - extensions: ${{ matrix.extensions }} - ini-values: ${{ matrix.ini-values }} - coverage: ${{ matrix.coverage }} - - - name: Setup problem matchers for PHP - run: echo "::add-matcher::${{ runner.tool_cache }}/php.json" - - # Setup PCOV since we're using PHPUnit < 8 which has it integrated. Requires PHP 7.1. - # Ignore platform reqs to make it install on PHP 8. - # https://github.com/krakjoe/pcov-clobber - - name: Setup PCOV - if: ${{ matrix.php == 8.0 }} - run: | - composer require pcov/clobber --ignore-platform-reqs - vendor/bin/pcov clobber - - - name: Setup Problem Matchers for PHPUnit - run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" - - - name: Install Composer dependencies - uses: ramsey/composer-install@v1 - with: - composer-options: "${{ matrix.composer-options }}" - - - name: Start MySQL Service - run: sudo systemctl start mysql.service - - - name: Prepare environment for integration tests - run: composer prepare-ci - - - name: Run integration tests (single site) - if: ${{ matrix.php != 8.0 }} - run: composer test - - name: Run integration tests (single site with code coverage) - if: ${{ matrix.php == 8.0 }} - run: composer coverage-ci - - name: Run integration tests (multisite) - run: composer test-ms diff --git a/cheeztest-examples.php b/cheeztest-examples.php index 8e866e0..359e390 100644 --- a/cheeztest-examples.php +++ b/cheeztest-examples.php @@ -1,29 +1,36 @@ 'chrome-ab', - 'groups' => array( - 'active' => array( + 'name' => 'chrome-ab', + 'groups' => array( + 'active' => array( 'threshold' => 30, ), 'control' => array( 'threshold' => 70, - ) + ), ), - 'is_qualified' => 'return stripos( $_SERVER[ "HTTP_USER_AGENT" ], "Chrome" ) !== false;' + 'is_qualified' => 'return stripos( $_SERVER[ "HTTP_USER_AGENT" ], "Chrome" ) !== false;', ) ); -// This will display a fixed position bar at the bottom of the screen to users in the test condition (i.e. 30% of Chrome users) +// This will display a fixed position bar at the bottom of the screen to users in the test condition (i.e. 30% of Chrome users). if ( CheezTest::is_in_group( 'chrome-ab', 'active' ) ) { - add_action( 'wp_footer', function() { - ?> + add_action( + 'wp_footer', + function() { + ?>
Howdy, Chrome user!
@@ -44,6 +51,7 @@ line-height: 28px; } - check if user is qualified to participate > - * get segment from either server or cookie > execute 'action' callback if present - * > write segment cookie if neccessary - * - * User's qualification, segment, and group tests are done in batcache - * so as to ensure correct cache variants are served. - * - * User's segment is assigned via client-side javascript. Mutliple test - * segments are assigned at once - so if a user is qualified to participate - * in more than one test, all segments are assigned at the same time. When - * segments need to be set, a small javascript is injected into the - * via a call to CheezTest::write_segment_cookie(). This javascript sets a - * cookie to retain the assigned segment. - * - * Test case data (name, is_qualified, & group) are stored in the $active_tests - * static hash and made accessible via the 'is_qualified_for', 'get_group_for', and - * 'is_in_group' static methods. This enables theme branching via: - * - * if ( CheezTest::is_qualified_for( 'my-example-test' ) { - * //test-specific stuff goes here - * } * - * - or - - * - * if ( CheezTest::is_in_group( 'my-example-test', 'my-example-group' ) ) { - * //group-specific stuff goes here - * } - * - * @access public - * @author Matt Mirande, I Can Haz Cheezburger - * @author Mohammad Jangda, Automattic - * * @license GPL v2 + * @package CheezTest */ -class CheezTest { - - public $name = ''; - public $group = null; - public $is_qualified = true; - private $expires = 0; - /** - * @access public - * @staticvar array [ $active_tests ] key / val map with name, is_qualified, - * and group for all child objects. Enables easier accesss - * across the application via "is_qualified_for", etc helpers. - */ - public static $active_tests = array(); - - private static $is_excluded = false; - - /** - * __construct - main constructor routine - * - * Configures and initiates an A/B test object. - * - * @access public - * @param array [ $opts ] key / value map containing configuration - * options. Options include: name, expires, groups, action, - * and is_qualified settings. The group property can optionally - * contain a simple array of strings representing group names or each - * group can be represented as an object with a 'threshold' property. - * The 'threshold' will then be used to establish the group size - - * e.g. 'threshold' => 10 indicates 10% of segments (segments 0 - 9) - * will be assigned to this group. - * - */ - function __construct( $opts = array() ) { - //exclude non-user requests - if ( ! static::is_user_request() ){ - return; - } - - $defaults = array( - 'name' => 'ab-test', - 'expires' => 31536000, //how long the user's segment cookie will persist - 'groups' => array( 'seg-group-a', 'seg-group-b' ), - 'is_qualified' => '', //accepts string that will be evaluated as func by batcache. - 'action' => false //accepts anon-func e.g. function( $group ){} or false to bypass - ); - - //merge opts w/ defaults to establish child's configuration - $cfg = array_merge( $defaults, $opts ); - - $this->name = $cfg[ "name" ]; - - //establish basic eligibility if 'is_qualified' is empty, all visitors are qualified to receive a segment - if ( ! empty( $cfg[ 'is_qualified' ] ) && ! $this->qualify_user( $cfg[ "is_qualified" ] ) ) { - return; - } - - //establish user's group - $this->group = $this->assign_group( $cfg[ "groups" ] ); - - //add object's state to active tests collection to enable theme branching - static::$active_tests[ $this->name ] = array( - 'is_qualified' => $this->is_qualified, - 'group' => $this->group - ); - - //fire 'action' callback if present - if ( $this->group && is_callable( $cfg[ "action" ] ) ) { - $cfg[ "action" ]( $this->group ); - } - - //if segment needs to be recorded in cookie, enqueue JS to do so - if ( ! $this->has_segment_cookie() ) { - $this->expires = $cfg[ 'expires' ] ? ( gmdate( 'r', time() + $cfg[ 'expires' ] ) ) : 0; - add_action( 'wp_print_footer_scripts' , array( $this, 'write_segment_cookie' ), 1 ); - } - } - - /** - * qualify_user - batcahe-friendly test to determine if user is qualified for test - * - * Determines if user is eligible to participate in AB test by - * running an arbitrary function body provided via $test argument. - * Result is stored within object via $this->is_qualified. - * - * @access private - * @param string [ $test ] function body used to determine user's - * eligibility. - * @return bool true if the user is eligible to participate - */ - private function qualify_user( $test ) { - $this->is_qualified = static::run_vary_cache_func( $test ); - return $this->is_qualified; - } - - /** - * has_segment_cookie - batcahe-friendly test to determine if user has a segment cookie - * - * @access private - * @return bool whether or not segment cookie exists - */ - private function has_segment_cookie(){ - $test = sprintf( 'return (bool) isset( $_COOKIE["%s"] );', $this->name ); - return static::run_vary_cache_func( $test ); - } - - /** - * assign_group - assign a segment group (e.g. A, B, etc) - * - * Determines what group the assigned segment belongs in and - * creates a batcache-friendly test to ensure proper variant - * is shown. - * - * If the user has a segment cookie, the segment in the cookie - * is returned. If not, the server returns a random group based - * on the thresholds provided in the test config. - * - * @access private - * @param array [ $groups ] key / value map which contains the - * group names to use. Unless 'threshold' property is supplied - * the count of the array's items determines how many possible - * groups there are as well as each group's size. - * @return string group assigned (as derived from $groups argument array) - */ - private function assign_group( $groups ){ - - $segment_checks = array(); - $cookie_checks = array(); - $block_size = 100 / count( $groups ); - $block_end = $block_size; - $block_start = 0; - - //loop through groups and build up test logic to determine group assignment - foreach ( $groups as $group => $group_args ){ - - //use 'threshold' to determine group sizing if available - if ( isset( $group_args[ 'threshold' ] ) && is_array( $group_args ) ){ - $block_end = $block_start + ( int ) $group_args[ 'threshold' ]; - } else { - $group = $groups[ $group ]; - } - - if ( $block_end > 100 ){ - $block_end = 100; - } - - //setup batcache vary on cache conditions - $segment_checks[] = sprintf( - '( $seg_num >= %1$d && $seg_num < %2$d ) return "%3$s";', - $block_start, - $block_end, - $group - ); - - $cookie_checks[] = sprintf( - '( $_COOKIE["%1$s"] === "%2$s" ) return "%2$s";', - $this->name, - $group - ); - - //update block - $block_start = $block_end; - $block_end = $block_start + $block_size; - } - - // Take array of checks and turn into string of if/then return statements - $segment_checks_str = 'if' . implode( 'elseif', $segment_checks ); - $cookie_checks_str = 'if' . implode( 'elseif', $cookie_checks ); - - $test = sprintf( 'if( isset( $_COOKIE["%1$s"] ) ){ %2$s } else { $seg_num = rand( 0,99 ); %3$s }', $this->name, $cookie_checks_str, $segment_checks_str ); - return static::run_vary_cache_func( $test ); - } - - /** - * run_vary_cache_func - environment-neutral interface to batcache's "vary_cache_on_function" - * - * Establishes whether or not to use a new cache variant by - * running an arbitrary function body provided via $test argument. - * $test is run both locally and in the batcache. - * - * @access private - * @param string [ $test ] function body used to determine user's - * eligibility. Must be a string in order to work with - * WP's batcache 'vary_cache_on_function' feature. Must - * include one or more references to "$_" variables. - * @return mixed (bool | string | int) - */ - private static function run_vary_cache_func( $test ){ - - if ( preg_match('/include|require|echo|print|dump|export|open|sock|unlink|`|eval/i', $test) ) - trigger_error('Illegal word in cache variant function determiner.', E_USER_ERROR ); - - if ( !preg_match('/\$_/', $test) ) - trigger_error('Cache variant function should refer to at least one $_ variable.', E_USER_ERROR ); - - if ( function_exists( 'vary_cache_on_function' ) ) { - vary_cache_on_function( $test ); - } - - $test_func = create_function( '', $test ); - return $test_func(); - } - - /** - * is_user_request - Determines if the request type is one typically made - * by a user - * - * Test whether or not the request being processed matches what would typically - * be made by a user. Cron jobs, requests for the admin section or feeeds, and - * any request made by google, ms/bing, or yahoo's slurp bot are flagged. - * - * @access public - * @return bool true if request appears to be user-generated - */ - private static function is_user_request(){ - //bail if we already know this request is not valid - if ( static::$is_excluded ){ - return false; - } - - $is_bot_test = 'return stripos( $_SERVER[ "HTTP_USER_AGENT" ], "googlebot" ) !== false || ' . - 'stripos( $_SERVER[ "HTTP_USER_AGENT" ], "bingbot" ) !== false || ' . - 'stripos( $_SERVER[ "HTTP_USER_AGENT" ], "msnbot" ) !== false || ' . - 'stripos( $_SERVER[ "HTTP_USER_AGENT" ], "slurp" ) !== false || ' . - 'stripos( $_SERVER[ "HTTP_USER_AGENT" ], "feedburner" ) !== false || ' . - 'stripos( $_SERVER[ "HTTP_USER_AGENT" ], "facebook" ) !== false || ' . - 'stripos( $_SERVER[ "HTTP_USER_AGENT" ], "technoratisnoop" ) !== false;'; - - $is_bot = static::run_vary_cache_func( $is_bot_test ); - - if ( ( defined( 'DOING_CRON' ) && DOING_CRON ) - || ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) - || ( defined( 'WP_ADMIN' ) && WP_ADMIN ) - || is_admin() - || $is_bot ) { - static::$is_excluded = true; - return false; - } - return true; - } - - /** - * write_segment_cookie - serves javascript response to client to write the user's - * segment into a cookie so it will persist across visits - * - * @return - */ - function write_segment_cookie(){ - ?> - - check if user is qualified to participate > + * get segment from either server or cookie > execute 'action' callback if present + * > write segment cookie if neccessary + * + * User's qualification, segment, and group tests are done in batcache + * so as to ensure correct cache variants are served. + * + * User's segment is assigned via client-side javascript. Mutliple test + * segments are assigned at once - so if a user is qualified to participate + * in more than one test, all segments are assigned at the same time. When + * segments need to be set, a small javascript is injected into the + * via a call to CheezTest::write_segment_cookie(). This javascript sets a + * cookie to retain the assigned segment. + * + * Test case data (name, is_qualified, & group) are stored in the $active_tests + * static hash and made accessible via the 'is_qualified_for', 'get_group_for', and + * 'is_in_group' static methods. This enables theme branching via: + * + * if ( CheezTest::is_qualified_for( 'my-example-test' ) { + * //test-specific stuff goes here + * } + * + * - or - + * + * if ( CheezTest::is_in_group( 'my-example-test', 'my-example-group' ) ) { + * //group-specific stuff goes here + * } + */ +class CheezTest { + + /** + * Configuration name. + * + * @var string + */ + public $name = ''; + + /** + * User's assigned group. + * + * @var null|string + */ + public $group = null; + + /** + * Condition to be evaluated as func by batcache. + * + * @var bool|string + */ + public $is_qualified = true; + + /** + * How long the user's segment cookie will persist. + * + * @var int + */ + private $expires = 0; + + /** + * Key / val map with name, is_qualified, + * and group for all child objects. Enables easier accesss + * across the application via "is_qualified_for", etc helpers. + * + * @access public + * @var array + */ + public static $active_tests = array(); + + /** + * If the current request should be excluded from the test. + * + * @var bool + */ + private static $is_excluded = false; + + /** + * Main constructor routine + * + * Configures and initiates an A/B test object. + * + * @access public + * @param array $opts key / value map containing configuration + * options. Options include: name, expires, groups, action, + * and is_qualified settings. The group property can optionally + * contain a simple array of strings representing group names or each + * group can be represented as an object with a 'threshold' property. + * The 'threshold' will then be used to establish the group size - + * e.g. 'threshold' => 10 indicates 10% of segments (segments 0 - 9) + * will be assigned to this group. + */ + public function __construct( $opts = array() ) { + // exclude non-user requests. + if ( ! static::is_user_request() ) { + return; + } + + $defaults = array( + 'name' => 'ab-test', + 'expires' => 31536000, // how long the user's segment cookie will persist. + 'groups' => array( 'seg-group-a', 'seg-group-b' ), + 'is_qualified' => '', // accepts string that will be evaluated as func by batcache. + 'action' => false, // accepts anon-func e.g. function( $group ){} or false to bypass. + ); + + // merge opts w/ defaults to establish child's configuration. + $cfg = array_merge( $defaults, $opts ); + + $this->name = $cfg['name']; + + // establish basic eligibility if 'is_qualified' is empty, all visitors are qualified to receive a segment. + if ( ! empty( $cfg['is_qualified'] ) && ! $this->qualify_user( $cfg['is_qualified'] ) ) { + return; + } + + // establish user's group. + $this->group = $this->assign_group( $cfg['groups'] ); + + // add object's state to active tests collection to enable theme branching. + static::$active_tests[ $this->name ] = array( + 'is_qualified' => $this->is_qualified, + 'group' => $this->group, + ); + + // fire 'action' callback if present. + if ( $this->group && is_callable( $cfg['action'] ) ) { + $cfg['action']( $this->group ); + } + + // if segment needs to be recorded in cookie, enqueue JS to do so. + if ( ! $this->has_segment_cookie() ) { + $this->expires = $cfg['expires'] ? ( gmdate( 'r', time() + $cfg['expires'] ) ) : 0; + add_action( 'wp_print_footer_scripts', array( $this, 'write_segment_cookie' ), 1 ); + } + } + + /** + * Batcahe-friendly test to determine if user is qualified for test + * + * Determines if user is eligible to participate in AB test by + * running an arbitrary function body provided via $test argument. + * Result is stored within object via $this->is_qualified. + * + * @access private + * @param string $test Function body used to determine user's + * eligibility. + * @return bool true if the user is eligible to participate + */ + private function qualify_user( $test ) { + $this->is_qualified = static::run_vary_cache_func( $test ); + return $this->is_qualified; + } + + /** + * Batcahe-friendly test to determine if user has a segment cookie + * + * @access private + * @return bool whether or not segment cookie exists + */ + private function has_segment_cookie() { + $test = sprintf( 'return (bool) isset( $_COOKIE["%s"] );', $this->name ); + return static::run_vary_cache_func( $test ); + } + + /** + * Assign a segment group (e.g. A, B, etc) + * + * Determines what group the assigned segment belongs in and + * creates a batcache-friendly test to ensure proper variant + * is shown. + * + * If the user has a segment cookie, the segment in the cookie + * is returned. If not, the server returns a random group based + * on the thresholds provided in the test config. + * + * @access private + * @param array $groups key / value map which contains the + * group names to use. Unless 'threshold' property is supplied + * the count of the array's items determines how many possible + * groups there are as well as each group's size. + * @return string group assigned (as derived from $groups argument array) + */ + private function assign_group( $groups ) { + $segment_checks = array(); + $cookie_checks = array(); + $block_size = 100 / count( $groups ); + $block_end = $block_size; + $block_start = 0; + + // loop through groups and build up test logic to determine group assignment. + foreach ( $groups as $group => $group_args ) { + + // use 'threshold' to determine group sizing if available. + if ( isset( $group_args['threshold'] ) && is_array( $group_args ) ) { + $block_end = $block_start + (int) $group_args['threshold']; + } else { + $group = $groups[ $group ]; + } + + if ( $block_end > 100 ) { + $block_end = 100; + } + + // setup batcache vary on cache conditions. + $segment_checks[] = sprintf( + '( $seg_num >= %1$d && $seg_num < %2$d ) return "%3$s";', + $block_start, + $block_end, + $group + ); + + $cookie_checks[] = sprintf( + '( $_COOKIE["%1$s"] === "%2$s" ) return "%2$s";', + $this->name, + $group + ); + + // update block. + $block_start = $block_end; + $block_end = $block_start + $block_size; + } + + // Take array of checks and turn into string of if/then return statements. + $segment_checks_str = 'if' . implode( 'elseif', $segment_checks ); + $cookie_checks_str = 'if' . implode( 'elseif', $cookie_checks ); + + $test = sprintf( 'if( isset( $_COOKIE["%1$s"] ) ){ %2$s } else { $seg_num = rand( 0,99 ); %3$s }', $this->name, $cookie_checks_str, $segment_checks_str ); + return static::run_vary_cache_func( $test ); + } + + /** + * Environment-neutral interface to batcache's "vary_cache_on_function" + * + * Establishes whether or not to use a new cache variant by + * running an arbitrary function body provided via $test argument. + * $test is run both locally and in the batcache. + * + * @access private + * @param string $test Function body used to determine user's + * eligibility. Must be a string in order to work with + * WP's batcache 'vary_cache_on_function' feature. Must + * include one or more references to "$_" variables. + * @return bool|string|int + */ + private static function run_vary_cache_func( $test ) { + if ( preg_match( '/include|require|echo|print|dump|export|open|sock|unlink|`|eval/i', $test ) ) { + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error + trigger_error( 'Illegal word in cache variant function determiner.', E_USER_ERROR ); + } + + if ( ! preg_match( '/\$_/', $test ) ) { + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error + trigger_error( 'Cache variant function should refer to at least one $_ variable.', E_USER_ERROR ); + } + + if ( function_exists( 'vary_cache_on_function' ) ) { + vary_cache_on_function( $test ); + } + + // @todo create_function() is deprecated as of PHP 7.2, please use full fledged functions or anonymous functions instead. (WordPress.PHP.RestrictedPHPFunctions.create_function_create_function) + $test_func = create_function( '', $test ); + return $test_func(); + } + + /** + * Determines if the request type is one typically made + * by a user + * + * Test whether or not the request being processed matches what would typically + * be made by a user. Cron jobs, requests for the admin section or feeeds, and + * any request made by google, ms/bing, or yahoo's slurp bot are flagged. + * + * @access private + * @return bool true if request appears to be user-generated + */ + private static function is_user_request() { + // bail if we already know this request is not valid. + if ( static::$is_excluded ) { + return false; + } + + $is_bot_test = 'return stripos( $_SERVER[ "HTTP_USER_AGENT" ], "googlebot" ) !== false || ' . + 'stripos( $_SERVER[ "HTTP_USER_AGENT" ], "bingbot" ) !== false || ' . + 'stripos( $_SERVER[ "HTTP_USER_AGENT" ], "msnbot" ) !== false || ' . + 'stripos( $_SERVER[ "HTTP_USER_AGENT" ], "slurp" ) !== false || ' . + 'stripos( $_SERVER[ "HTTP_USER_AGENT" ], "feedburner" ) !== false || ' . + 'stripos( $_SERVER[ "HTTP_USER_AGENT" ], "facebook" ) !== false || ' . + 'stripos( $_SERVER[ "HTTP_USER_AGENT" ], "technoratisnoop" ) !== false;'; + + $is_bot = static::run_vary_cache_func( $is_bot_test ); + + if ( ( defined( 'DOING_CRON' ) && DOING_CRON ) + || ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) + || ( defined( 'WP_ADMIN' ) && WP_ADMIN ) + || is_admin() + || $is_bot ) { + static::$is_excluded = true; + return false; + } + return true; + } + + /** + * Serves javascript response to client to write the user's + * segment into a cookie so it will persist across visits + */ + public function write_segment_cookie() { + ?> + + _toc = $cheeztest; - } -}