Skip to content

Commit

Permalink
Code hacker resdesign for single-load of code files.
Browse files Browse the repository at this point in the history
The code hacker as originally designed, as a mechanism that allowed
to enable hacks at the individual test level, is flawed because it
assumes that code files are loaded before each test, but actually
the PHP engine loads code files only once.

Therefore this commit redesigns it so that the two existing main hacks,
the functions mocker and the static methods hacker, are applied
to all the relevant functions and classes at bootstrap time, and
mocks for each individual function/method can be registered at the
beginning of each test. See README for the full details.
  • Loading branch information
Konamiman committed Jun 2, 2020
1 parent 2a68bb0 commit bfda3f9
Show file tree
Hide file tree
Showing 12 changed files with 491 additions and 612 deletions.
94 changes: 38 additions & 56 deletions tests/Tools/CodeHacking/CodeHacker.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
namespace Automattic\WooCommerce\Testing\Tools\CodeHacking;

use \ReflectionObject;
use \ReflectionFunction;
use \ReflectionException;

/**
Expand Down Expand Up @@ -55,7 +54,7 @@ final class CodeHacker {
*
* @var array
*/
private static $path_white_list = array();
private static $paths_with_files_to_hack = array();

/**
* Registered hacks.
Expand All @@ -64,13 +63,6 @@ final class CodeHacker {
*/
private static $hacks = array();

/**
* Registered persistent hacks.
*
* @var array
*/
private static $persistent_hacks = array();

/**
* Is the code hacker enabled?.
*
Expand Down Expand Up @@ -99,13 +91,6 @@ public static function disable() {
}
}

/**
* Unregister all the non-persistent registered hacks.
*/
public static function clear_hacks() {
self::$hacks = self::$persistent_hacks;
}

/**
* Check if the code hacker is enabled.
*
Expand All @@ -116,48 +101,29 @@ public static function is_enabled() {
}

/**
* Check if persistent hacks have been registered.
*
* @return bool True if persistent hacks have been registered.
* Execute the 'reset()' method in all the registered hacks.
*/
public static function has_persistent_hacks() {
return count( self::$persistent_hacks ) > 0;
public static function reset_hacks() {
foreach ( self::$hacks as $hack ) {
call_user_func( array( $hack, 'reset' ) );
}
}

/**
* Register a new hack.
*
* @param mixed $hack A function with signature "hack($code, $path)" or an object containing a method with that signature.
* @param bool $persistent If true, the hack will be registered as persistent (so that clear_hacks will not clear it).
* @throws \Exception Invalid input.
*/
public static function add_hack( $hack, $persistent = false ) {
if ( ! is_callable( $hack ) && ! is_object( $hack ) ) {
throw new \Exception( "CodeHacker::addhack: Hacks must be either functions, or objects having a 'process(\$text, \$path)' method." );
}

if ( ! self::is_valid_hack_callback( $hack ) && ! self::is_valid_hack_object( $hack ) ) {
throw new \Exception( "CodeHacker::addhack: Hacks must be either a function with a 'hack(\$code,\$path)' signature, or an object containing a public method 'hack' with that signature. " );
public static function add_hack( $hack ) {
if ( ! self::is_valid_hack_object( $hack ) ) {
$class = get_class( $hack );
throw new \Exception( "CodeHacker::addhack for instance of $class: Hacks must be objects having a 'process(\$text, \$path)' method and a 'reset()' method." );
}

if ( $persistent ) {
self::$persistent_hacks[] = $hack;
}
self::$hacks[] = $hack;
}

/**
* Check if the supplied argument is a valid hack callback (has two mandatory arguments).
*
* @param mixed $callback Argument to check.
*
* @return bool true if the argument is a valid hack callback, false otherwise.
* @throws ReflectionException Error when instantiating ReflectionFunction.
*/
private static function is_valid_hack_callback( $callback ) {
return is_callable( $callback ) && HACK_CALLBACK_ARGUMENT_COUNT === ( new ReflectionFunction( $callback ) )->getNumberOfRequiredParameters();
}

/**
* Check if the supplied argument is a valid hack object (has a public "hack" method with two mandatory arguments).
*
Expand All @@ -172,20 +138,34 @@ private static function is_valid_hack_object( $callback ) {

$ro = new ReflectionObject( ( $callback ) );
try {
$rm = $ro->getMethod( 'hack' );
return $rm->isPublic() && ! $rm->isStatic() && 2 === $rm->getNumberOfRequiredParameters();
$rm = $ro->getMethod( 'hack' );
$has_valid_hack_method = $rm->isPublic() && ! $rm->isStatic() && 2 === $rm->getNumberOfRequiredParameters();

$rm = $ro->getMethod( 'reset' );
$has_valid_reset_method = $rm->isPublic() && ! $rm->isStatic() && 0 === $rm->getNumberOfRequiredParameters();

return $has_valid_hack_method && $has_valid_reset_method;
} catch ( ReflectionException $exception ) {
return false;
}
}

/**
* Set the white list of files to hack. If note set, all the PHP files will be hacked.
* Initialize the code hacker.
*
* @param array $path_white_list Paths of the files to hack, can be relative paths.
* @param array $paths Paths of the directories containing the files to hack.
* @throws \Exception Invalid input.
*/
public static function set_white_list( array $path_white_list ) {
self::$path_white_list = $path_white_list;
public static function initialize( array $paths ) {
if ( ! is_array( $paths ) || empty( $paths ) ) {
throw new \Exception( 'CodeHacker::initialize - $paths must be a non-empty array with the directories containing the files to be hacked.' );
}
self::$paths_with_files_to_hack = array_map(
function( $path ) {
return realpath( $path );
},
$paths
);
}

/**
Expand Down Expand Up @@ -352,7 +332,7 @@ public function stream_metadata( $path, $option, $value ) {
*/
public function stream_open( $path, $mode, $options, &$opened_path ) {
$use_path = (bool) ( $options & STREAM_USE_PATH );
if ( 'rb' === $mode && self::path_in_white_list( $path ) && 'php' === pathinfo( $path, PATHINFO_EXTENSION ) ) {
if ( 'rb' === $mode && self::path_in_list_of_paths_to_hack( $path ) && 'php' === pathinfo( $path, PATHINFO_EXTENSION ) ) {
$content = $this->native( 'file_get_contents', $path, $use_path, $this->context );
if ( false === $content ) {
return false;
Expand Down Expand Up @@ -511,13 +491,15 @@ private static function hack( $code, $path ) {
* @param string $path File path to check.
*
* @return bool TRUE if there's an entry in the white list that ends with $path, FALSE otherwise.
*
* @throws \Exception The class is not initialized.
*/
private static function path_in_white_list( $path ) {
if ( empty( self::$path_white_list ) ) {
return true;
private static function path_in_list_of_paths_to_hack( $path ) {
if ( empty( self::$paths_with_files_to_hack ) ) {
throw new \Exception( "CodeHacker is not initialized, it must initialized by invoking 'initialize'" );
}
foreach ( self::$path_white_list as $white_list_item ) {
if ( substr( $path, -strlen( $white_list_item ) ) === $white_list_item ) {
foreach ( self::$paths_with_files_to_hack as $white_list_item ) {
if ( substr( $path, 0, strlen( $white_list_item ) ) === $white_list_item ) {
return true;
}
}
Expand Down
155 changes: 3 additions & 152 deletions tests/Tools/CodeHacking/CodeHackerTestHook.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,73 +8,15 @@
namespace Automattic\WooCommerce\Testing\Tools\CodeHacking;

use PHPUnit\Runner\BeforeTestHook;
use PHPUnit\Runner\AfterTestHook;
use PHPUnit\Util\Test;
use ReflectionClass;
use ReflectionMethod;
use Exception;

/**
* Helper to use the CodeHacker class in PHPUnit.
* Helper to use the CodeHacker class in PHPUnit. To use, add this to phpunit.xml:
*
* How to use:
*
* 1. Add this to phpunit.xml:
*
* <extensions>
* <extension class="CodeHackerTestHook" />
* </extensions>
*
* 2. Add the following to the test classes:
*
* use Automattic\WooCommerce\Testing\Tools\CodeHacking\CodeHacker;
* use Automattic\WooCommerce\Testing\Tools\CodeHacking\Hacks\...
*
* public static function before_all($method_name) {
* CodeHacker::add_hack(...);
* //Register as many hacks as needed
* CodeHacker::enable();
* }
*
* $method_name is optional, 'before_all()' is also a valid method signature.
*
* You can also define a test-specific 'before_{$test_method_name}' hook.
* If both exist, first 'before_all' will be executed, then the test-specific one.
*
* 3. Additionally, you can register hacks via class/method annotations
* (note that then you don't need the `use`s anymore):
*
* /**
* * @hack HackClassName param1 param2
* * /
* class Some_Test
* {
* /**
* * @hack HackClassName param1 param2
* * /
* public function test_something() {
* }
* }
*
* If the class name ends with 'Hack' you can omit that suffix in the annotation (e.g. 'Foo' instead of 'FooHack').
* Parameters specified after the class name will be passed to the class constructor.
* Hacks defined as class annotations will be applied to all tests.
*/
final class CodeHackerTestHook implements BeforeTestHook, AfterTestHook {

// phpcs:disable PHPCompatibility.FunctionDeclarations.NewReturnTypeDeclarations.voidFound

/**
* Runs after each test.
*
* @param string $test "TestClass::TestMethod".
* @param float $time The time it took the test to run, in seconds.
*/
public function executeAfterTest( string $test, float $time ): void {
if ( ! CodeHacker::has_persistent_hacks() ) {
CodeHacker::disable();
}
}
final class CodeHackerTestHook implements BeforeTestHook {

/**
* Runs before each test.
Expand All @@ -84,98 +26,7 @@ public function executeAfterTest( string $test, float $time ): void {
* @throws \ReflectionException Thrown by execute_before_methods.
*/
public function executeBeforeTest( string $test ): void {
/**
* Possible formats of $test:
* TestClass::TestMethod
* TestClass::TestMethod with data set #...
* Warning
*/
$parts = explode( '::', $test );
if ( count( $parts ) < 2 ) {
return; // "Warning" was supplied as argument
}
$class_name = $parts[0];
$method_name = explode( ' ', $parts[1] )[0];

CodeHacker::clear_hacks();

$this->execute_before_methods( $class_name, $method_name );

$has_class_annotation_hacks = $this->add_hacks_from_annotations( new ReflectionClass( $class_name ) );
$has_method_annotaion_hacks = $this->add_hacks_from_annotations( new ReflectionMethod( $class_name, $method_name ) );
if ( $has_class_annotation_hacks || $has_method_annotaion_hacks || CodeHacker::has_persistent_hacks() ) {
CodeHacker::enable();
}
}

// phpcs:enable PHPCompatibility.FunctionDeclarations.NewReturnTypeDeclarations.voidFound

/**
* Apply hacks defined in @hack annotations.
*
* @param object $reflection_object The class or method reflection object whose doc comment will be parsed.
* @return bool True if at least one valid @hack annotation was found.
* @throws Exception Class specified in @hack directive doesn't exist.
*/
private function add_hacks_from_annotations( $reflection_object ) {
$annotations = Test::parseAnnotations( $reflection_object->getDocComment() );
$hacks_added = false;

foreach ( $annotations as $id => $annotation_instances ) {
if ( 'hack' !== $id ) {
continue;
}

foreach ( $annotation_instances as $annotation ) {
preg_match_all( '/"(?:\\\\.|[^\\\\"])*"|\S+/', $annotation, $matches );
$params = $matches[0];

$hack_class = array_shift( $params );
if ( false === strpos( $hack_class, '\\' ) ) {
$hack_class = __NAMESPACE__ . '\\Hacks\\' . $hack_class;
}

if ( ! class_exists( $hack_class ) ) {
$original_hack_class = $hack_class;
$hack_class .= 'Hack';
if ( ! class_exists( $hack_class ) ) {
throw new Exception( "Hack class '{$original_hack_class}' defined via annotation in {$class_name}::{$method_name} doesn't exist." );
}
}

CodeHacker::add_hack( new $hack_class( ...$params ) );
$hacks_added = true;
}
}

return $hacks_added;
}

/**
* Run the 'before_all' and 'before_{test_method_name}' methods in a class.
*
* @param string $class_name Test class name.
* @param string $method_name Test method name.
* @throws ReflectionException Error when instatiating a ReflectionClass.
*/
private function execute_before_methods( $class_name, $method_name ) {
$methods = array( 'before_all', "before_{$method_name}" );
$methods = array_filter(
$methods,
function( $item ) use ( $class_name ) {
return method_exists( $class_name, $item );
}
);

$rc = new ReflectionClass( $class_name );

foreach ( $methods as $method ) {
if ( 0 === $rc->getMethod( $method_name )->getNumberOfParameters() ) {
$class_name::$method();
} else {
$class_name::$method( $method_name );
}
}
CodeHacker::reset_hacks();
}
}

16 changes: 13 additions & 3 deletions tests/Tools/CodeHacking/Hacks/BypassFinalsHack.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@
* @package WooCommerce/Testing
*/

// phpcs:disable Squiz.Commenting.FunctionComment.Missing

namespace Automattic\WooCommerce\Testing\Tools\CodeHacking\Hacks;

/**
Expand All @@ -16,6 +14,13 @@
*/
final class BypassFinalsHack extends CodeHack {

/**
* Hacks code by removing "final" keywords from class definitions.
*
* @param string $code The code to hack.
* @param string $path The path of the file containing the code to hack.
* @return string The hacked code.
*/
public function hack( $code, $path ) {
if ( stripos( $code, 'final' ) !== false ) {
$tokens = $this->tokenize( $code );
Expand All @@ -27,6 +32,11 @@ public function hack( $code, $path ) {

return $code;
}

/**
* Revert the hack to its initial state - nothing to do since finals can't be reverted.
*/
public function reset() {
}
}

// phpcs:enable Squiz.Commenting.FunctionComment.Missing
5 changes: 5 additions & 0 deletions tests/Tools/CodeHacking/Hacks/CodeHack.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ abstract class CodeHack {
*/
abstract public function hack( $code, $path);

/**
* Revert the hack to its initial state.
*/
abstract public function reset();

/**
* Tokenize PHP source code.
*
Expand Down
Loading

0 comments on commit bfda3f9

Please sign in to comment.