Barebones implementation of a code hacker for unit tests.

The "code hacker" is a class that hooks on filesystem events
(using stream_wrapper_unregister) in order to allow for dynamically
modifying the content of PHP code files while they are loaded.
The code hacker class allows registering hacks, which are
functions that take source code as input and return the modified code.
A hack can be a standalone function or a class with a "hack" method.

A few hacks are provided off the shelf. One allows mocking standalone
PHP functions (WP, WOO or not), another one allows mocking static
methods, and there's the one that removes the "final" qualifier
from a class definition. This helps unit testing stuff that would
otherwise be quite hard to test.
This commit is contained in:
Nestor Soriano 2020-04-09 16:32:40 +02:00
parent 4750a2d567
commit db58b51de3
7 changed files with 538 additions and 0 deletions

View File

@ -49,4 +49,7 @@
<listeners>
<listener class="SpeedTrapListener" file="tests/legacy/includes/listener-loader.php" />
</listeners>
<extensions>
<extension class="CodeHackerTestHook" file="tests/includes/code-hacking/code-hacker-test-hook.php" />
</extensions>
</phpunit>

View File

@ -0,0 +1,64 @@
<?php
use PHPUnit\Runner\BeforeTestHook;
/**
* Helper to use the CodeHacker class in PHPUnit.
*
* How to use:
*
* 1. Add this to phpunit.xml:
*
* <extensions>
* <extension class="CodeHackerTestHook" file="path/to/code-hacker-test-hook.php" />
* </extensions>
*
* 2. Add the following to the test classes:
*
* 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.
*/
final class CodeHackerTestHook implements BeforeTestHook {
public function executeBeforeTest( string $test ): void {
$parts = explode( '::', $test );
$class_name = $parts[0];
$method_name = $parts[1];
$methods = array( 'before_all', "before_{$method_name}" );
$methods = array_filter(
$methods,
function( $item ) use ( $class_name ) {
return method_exists( $class_name, $item );
}
);
if ( empty( $methods ) ) {
return;
}
// Make code hacker class and individual hack classes available to tests
include_once __DIR__ . '/code-hacker.php';
foreach ( glob( __DIR__ . '/hacks/*.php' ) as $hack_class_file ) {
include_once $hack_class_file;
}
$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 );
}
}
}
}

View File

@ -0,0 +1,241 @@
<?php
/**
* CodeHacker - allows to hack (alter on the fly) the content of PHP code files.
*
* Based on BypassFinals: https://github.com/dg/bypass-finals
*
* How to use:
*
* 1. Register hacks using CodeHacker::add_hack(hack). A hack is either:
* - A function with 'hack($code, $path)' signature, or
* - An object having a public 'hack($code, $path)' method.
*
* Where $code is a string containing the code to hack, and $path is the full path of the file
* containing the code. The function/method must return a string with the code already hacked.
*
* 2. Run CodeHacker::enable()
*
* For using with PHPUnit, see CodeHackerTestHook.
*/
class CodeHacker {
const PROTOCOL = 'file';
/** @var resource|null */
public $context;
/** @var resource|null */
private $handle;
/** @var array|null */
private static $pathWhitelist = array();
private static $hacks = array();
public static function enable() {
stream_wrapper_unregister( self::PROTOCOL );
stream_wrapper_register( self::PROTOCOL, __CLASS__ );
}
public static function add_hack( $hack ) {
if ( ! is_callable( $hack ) && ! is_object( $hack ) ) {
throw new Exception( "hacks must be either functions, or objects having a 'process(\$text, \$path)' method." );
}
// TODO: Check that callbacks have at least two parameters (if they have more, they must be optional); and that objects have a "process" method with the same condition.
self::$hacks[] = $hack;
}
public static function setWhitelist( array $pathWhitelist ) {
self::$pathWhitelist = $pathWhitelist;
}
public function dir_closedir() {
closedir( $this->handle );
}
public function dir_opendir( $path, $options ) {
$this->handle = $this->context
? $this->native( 'opendir', $path, $this->context )
: $this->native( 'opendir', $path );
return (bool) $this->handle;
}
public function dir_readdir() {
return readdir( $this->handle );
}
public function dir_rewinddir() {
return rewinddir( $this->handle );
}
public function mkdir( $path, $mode, $options ) {
$recursive = (bool) ( $options & STREAM_MKDIR_RECURSIVE );
return $this->native( 'mkdir', $path, $mode, $recursive, $this->context );
}
public function rename( $pathFrom, $pathTo ) {
return $this->native( 'rename', $pathFrom, $pathTo, $this->context );
}
public function rmdir( $path, $options ) {
return $this->native( 'rmdir', $path, $this->context );
}
public function stream_cast( $castAs ) {
return $this->handle;
}
public function stream_close() {
fclose( $this->handle );
}
public function stream_eof() {
return feof( $this->handle );
}
public function stream_flush() {
return fflush( $this->handle );
}
public function stream_lock( $operation ) {
return $operation
? flock( $this->handle, $operation )
: true;
}
public function stream_metadata( $path, $option, $value ) {
switch ( $option ) {
case STREAM_META_TOUCH:
$value += array( null, null );
return $this->native( 'touch', $path, $value[0], $value[1] );
case STREAM_META_OWNER_NAME:
case STREAM_META_OWNER:
return $this->native( 'chown', $path, $value );
case STREAM_META_GROUP_NAME:
case STREAM_META_GROUP:
return $this->native( 'chgrp', $path, $value );
case STREAM_META_ACCESS:
return $this->native( 'chmod', $path, $value );
}
}
public function stream_open( $path, $mode, $options, &$openedPath ) {
$usePath = (bool) ( $options & STREAM_USE_PATH );
if ( $mode === 'rb' && self::pathInWhitelist( $path ) && pathinfo( $path, PATHINFO_EXTENSION ) === 'php' ) {
$content = $this->native( 'file_get_contents', $path, $usePath, $this->context );
if ( $content === false ) {
return false;
}
$modified = self::hack( $content, $path );
if ( $modified !== $content ) {
$this->handle = tmpfile();
$this->native( 'fwrite', $this->handle, $modified );
$this->native( 'fseek', $this->handle, 0 );
return true;
}
}
$this->handle = $this->context
? $this->native( 'fopen', $path, $mode, $usePath, $this->context )
: $this->native( 'fopen', $path, $mode, $usePath );
return (bool) $this->handle;
}
public function stream_read( $count ) {
return fread( $this->handle, $count );
}
public function stream_seek( $offset, $whence = SEEK_SET ) {
return fseek( $this->handle, $offset, $whence ) === 0;
}
public function stream_set_option( $option, $arg1, $arg2 ) {
}
public function stream_stat() {
return fstat( $this->handle );
}
public function stream_tell() {
return ftell( $this->handle );
}
public function stream_truncate( $newSize ) {
return ftruncate( $this->handle, $newSize );
}
public function stream_write( $data ) {
return fwrite( $this->handle, $data );
}
public function unlink( $path ) {
return $this->native( 'unlink', $path );
}
public function url_stat( $path, $flags ) {
$func = $flags & STREAM_URL_STAT_LINK ? 'lstat' : 'stat';
return $flags & STREAM_URL_STAT_QUIET
? @$this->native( $func, $path )
: $this->native( $func, $path );
}
private function native( $func ) {
stream_wrapper_restore( self::PROTOCOL );
$res = call_user_func_array( $func, array_slice( func_get_args(), 1 ) );
stream_wrapper_unregister( self::PROTOCOL );
stream_wrapper_register( self::PROTOCOL, __CLASS__ );
return $res;
}
private static function hack( $code, $path ) {
foreach ( self::$hacks as $hack ) {
if ( is_callable( $hack ) ) {
$code = call_user_func( $hack, $code, $path );
} else {
$code = $hack->hack( $code, $path );
}
}
return $code;
}
private static function pathInWhitelist( $path ) {
if ( empty( self::$pathWhitelist ) ) {
return true;
}
foreach ( self::$pathWhitelist as $whitelistItem ) {
if ( substr( $path, -strlen( $whitelistItem ) ) === $whitelistItem ) {
return true;
}
}
return false;
}
}

View File

@ -0,0 +1,23 @@
<?php
require_once __DIR__ . '/code-hack.php';
/**
* Code hack to bypass finals.
*
* Removes all the "final" keywords from class definitions.
*/
final class BypassFinalsHack extends CodeHack {
public function hack( $code, $path ) {
if ( stripos( $code, 'final' ) !== false ) {
$tokens = $this->tokenize( $code );
$code = '';
foreach ( $tokens as $token ) {
$code .= $this->is_token_of_type( $token, T_FINAL ) ? '' : $this->token_to_string( $token );
}
}
return $code;
}
}

View File

@ -0,0 +1,67 @@
<?php
/**
* Base class to define hacks for CodeHacker.
*
* This class is included for convenience only, any class having a 'public function hack($code, $path)'
* can be used as a hack class for CodeHacker.
*/
abstract class CodeHack {
/**
* The hack method to implement.
*
* @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.
*/
abstract public function hack( $code, $path);
/**
* Tokenize PHP source code.
*
* @param string $code PHP code to tokenize.
* @return array Tokenized code.
*/
protected function tokenize( $code ) {
return PHP_VERSION_ID >= 70000 ? token_get_all( $code, TOKEN_PARSE ) : token_get_all( $code );
}
/**
* Check if a token is of a given type.
*
* @param mixed $token Token to check.
* @param int $type Type of token to check (see https://www.php.net/manual/en/tokens.php)
* @return bool True if it's a token of the given type, false otherwise.
*/
protected function is_token_of_type( $token, $type ) {
return is_array( $token ) && $type === $token[0];
}
/**
* Converts a token to its string representation.
*
* @param $token Token to convert.
* @return mixed String representation of the token.
*/
protected function token_to_string( $token ) {
return is_array( $token ) ? $token[1] : $token;
}
/**
* Checks if a string ends with a certain substring.
* This method is added to help processing path names within 'hack' methods needing to do so.
*
* @param string $haystack The string to search in.
* @param string $needle The substring to search for.
* @return bool True if the $haystack ends with $needle, false otherwise.
*/
protected function string_ends_with( $haystack, $needle ) {
$length = strlen( $needle );
if ( $length == 0 ) {
return true;
}
return ( substr( $haystack, -$length ) === $needle );
}
}

View File

@ -0,0 +1,53 @@
<?php
require_once __DIR__ . '/code-hack.php';
/**
* Hack to mock standalone functions.
*
* Usage:
*
* 1. Create a mock class that contains public static methods with the same
* names and signatures as the functions you want to mock.
*
* 2. Pass a 'new FunctionsMockerHack(mock_class_name)' to CodeHacker.
*/
final class FunctionsMockerHack extends CodeHack {
/**
* FunctionsMockerHack constructor.
*
* @param string $mock_class Name of the class containing function mocks as public static methods.
* @throws ReflectionException
*/
public function __construct( string $mock_class ) {
$this->mock_class = $mock_class;
$rc = new ReflectionClass( $mock_class );
$static_methods = $rc->getMethods( ReflectionMethod::IS_PUBLIC | ReflectionMethod::IS_STATIC );
$this->mocked_methods = array_map(
function( $item ) {
return $item->getName();
},
$static_methods
);
}
public function hack( $code, $path ) {
$tokens = $this->tokenize( $code );
$code = '';
$previous_token_is_object_operator = false;
foreach ( $tokens as $token ) {
if ( $this->is_token_of_type( $token, T_STRING ) && ! $previous_token_is_object_operator && in_array( $token[1], $this->mocked_methods ) ) {
$code .= "{$this->mock_class}::{$token[1]}";
} else {
$code .= $this->token_to_string( $token );
$previous_token_is_object_operator = $this->is_token_of_type( $token, T_DOUBLE_COLON ) || $this->is_token_of_type( $token, T_OBJECT_OPERATOR );
}
}
return $code;
}
}

View File

@ -0,0 +1,87 @@
<?php
require_once __DIR__ . '/code-hack.php';
/**
* Hack to mock public static methods and properties.
*
* How to use:
*
* 1. Create a mock class that contains public static methods and properties with the same
* names and signatures as the ones in the class you want to mock.
*
* 2. Pass a 'new StaticMockerHack(original_class_name, mock_class_name)' to CodeHacker.
*
* Invocations of public static members from the original class that exist in the mock class
* will be replaced with invocations of the same members for the mock class.
* Invocations of members not existing in the mock class will be left unmodified.
*/
final class StaticMockerHack extends CodeHack {
/**
* StaticMockerHack constructor.
*
* @param string $source_class Name of the original class (the one having the members to be mocked).
* @param string $mock_class Name of the mock class (the one having the replacement mock members).
* @throws ReflectionException
*/
public function __construct( string $source_class, string $mock_class ) {
$this->source_class = $source_class;
$this->target_class = $mock_class;
$rc = new ReflectionClass( $mock_class );
$static_methods = $rc->getMethods( ReflectionMethod::IS_PUBLIC | ReflectionMethod::IS_STATIC );
$static_methods = array_map(
function( $item ) {
return $item->getName();
},
$static_methods
);
$static_properties = $rc->getProperties( ReflectionMethod::IS_PUBLIC | ReflectionMethod::IS_STATIC );
$static_properties = array_map(
function( $item ) {
return '$' . $item->getName();
},
$static_properties
);
$this->members_implemented_in_mock = array_merge( $static_methods, $static_properties );
}
public function hack( $code, $path ) {
$last_item = null;
if ( stripos( $code, $this->source_class . '::' ) !== false ) {
$tokens = $this->tokenize( $code );
$code = '';
$current_token = null;
while ( $current_token = current( $tokens ) ) {
if ( $this->is_token_of_type( $current_token, T_STRING ) && $this->source_class === $current_token[1] ) {
$next_token = next( $tokens );
if ( $this->is_token_of_type( $next_token, T_DOUBLE_COLON ) ) {
$called_member = next( $tokens )[1];
if ( in_array( $called_member, $this->members_implemented_in_mock ) ) {
// Reference to source class member that exists in mock class, replace.
$code .= "{$this->target_class}::{$called_member}";
} else {
// Reference to source class member that does NOT exists in mock class, leave unchanged.
$code .= "{$this->source_class}::{$called_member}";
}
} else {
// Reference to source class, but not followed by '::'.
$code .= $this->token_to_string( $current_token ) . $this->token_to_string( $next_token );
}
} else {
// Not a reference to source class.
$code .= $this->token_to_string( $current_token );
}
next( $tokens );
}
}
return $code;
}
}