Avoid unnecessary custom test environment compilations (base compilation of file modification time)

This commit is contained in:
Romain Quetiez
2024-04-30 09:45:44 +02:00
parent ccaf2dc5b7
commit 53dc452d61
7 changed files with 147 additions and 223 deletions

View File

@@ -19,10 +19,6 @@
printerClass="Sempro\PHPUnitPrettyPrinter\PrettyPrinter"
>
<extensions>
<extension class="Combodo\iTop\Test\UnitTest\Hook\TestsRunStartHook" />
</extensions>
<php>
<ini name="error_reporting" value="E_ALL"/>
<ini name="display_errors" value="On"/>

View File

@@ -19,10 +19,6 @@
printerClass="Sempro\PHPUnitPrettyPrinter\PrettyPrinter"
>
<extensions>
<extension class="Combodo\iTop\Test\UnitTest\Hook\TestsRunStartHook" />
</extensions>
<php>
<ini name="error_reporting" value="E_ALL"/>
<ini name="display_errors" value="On"/>

View File

@@ -19,10 +19,6 @@
printerClass="Sempro\PHPUnitPrettyPrinter\PrettyPrinter"
>
<extensions>
<extension class="Combodo\iTop\Test\UnitTest\Hook\TestsRunStartHook" />
</extensions>
<php>
<ini name="error_reporting" value="E_ALL"/>
<ini name="display_errors" value="On"/>

View File

@@ -7,11 +7,9 @@
namespace Combodo\iTop\Test\UnitTest;
use CMDBSource;
use Combodo\iTop\Test\UnitTest\Hook\TestsRunStartHook;
use Combodo\iTop\Test\UnitTest\Service\UnitTestRunTimeEnvironment;
use Config;
use Exception;
use IssueLog;
use MetaModel;
use SetupUtils;
use utils;
@@ -31,9 +29,9 @@ use utils;
abstract class ItopCustomDatamodelTestCase extends ItopDataTestCase
{
/**
* @var bool[]
*/
protected static $aReadyCustomEnvironments = [];
* @var UnitTestRunTimeEnvironment
*/
protected $oEnvironment = null;
/**
* @inheritDoc
@@ -51,11 +49,19 @@ abstract class ItopCustomDatamodelTestCase extends ItopDataTestCase
$this->setRunClassInSeparateProcess(true);
}
/**
/**
* @return string Abs path to the XML delta to use for the tests of that class
*/
abstract public function GetDatamodelDeltaAbsPath(): string;
protected function setUp(): void
{
static::LoadRequiredItopFiles();
$this->oEnvironment = new UnitTestRunTimeEnvironment('production', $this->GetTestEnvironment());
parent::setUp();
}
/**
* @inheritDoc
*/
@@ -93,40 +99,16 @@ abstract class ItopCustomDatamodelTestCase extends ItopDataTestCase
}
/**
* Mark {@see \Combodo\iTop\Test\UnitTest\ItopDataTestCase::GetTestEnvironment()} as ready (compiled)
*
* @return void
*/
private function MarkEnvironmentReady(): void
{
if (false === $this->IsEnvironmentReady()) {
touch(static::GetTestEnvironmentFolderAbsPath());
}
}
/**
* @return bool True if the {@see \Combodo\iTop\Test\UnitTest\ItopDataTestCase::GetTestEnvironment()} is ready (compiled, but not started)
*
* @details Having the environment ready means that it has been compiled for this global tests run, not that it is a relic from a previous global tests run
* @return bool True if the {@see \Combodo\iTop\Test\UnitTest\ItopDataTestCase::GetTestEnvironment()} is ready (compiled, up-to-date, but not necessarily started)
*/
final protected function IsEnvironmentReady(): bool
{
// As these test cases run in separate processes, the best way we found to let know a process if its environment was already prepared for **this run** was to compare the modification times of:
// - its own env-<ENV> folder
// - a file generated at the beginning of the global test run {@see \Combodo\iTop\Test\UnitTest\Hook\TestsRunStartHook}
$sRunStartedFilePath = TestsRunStartHook::GetRunStartedFileAbsPath();
$sEnvFolderPath = static::GetTestEnvironmentFolderAbsPath();
clearstatcache();
if (false === file_exists($sRunStartedFilePath) || false === file_exists($sEnvFolderPath)) {
if (false === file_exists($this->GetTestEnvironmentFolderAbsPath())) {
return false;
}
$iRunStartedFileModificationTime = filemtime($sRunStartedFilePath);
$iEnvFolderModificationTime = filemtime($sEnvFolderPath);
return $iEnvFolderModificationTime >= $iRunStartedFileModificationTime;
}
return $this->oEnvironment->IsUpToDate();
}
/**
* @inheritDoc
@@ -141,6 +123,12 @@ abstract class ItopCustomDatamodelTestCase extends ItopDataTestCase
// Note: To improve performances, we compile all XML deltas from test cases derived from this class and make a single environment where everything will be ran at once.
// This requires XML deltas to be compatible, but it is a known and accepted trade-off. See PR #457
if (false === $this->IsEnvironmentReady()) {
$this->debug("Preparing custom environment '$sTestEnv' with the following datamodel files:");
foreach ($this->oEnvironment->GetCustomDatamodelFiles() as $sCustomDatamodelFile) {
$this->debug(" - $sCustomDatamodelFile");
}
//----------------------------------------------------
// Clear any previous "$sTestEnv" environment
//----------------------------------------------------
@@ -153,14 +141,6 @@ abstract class ItopCustomDatamodelTestCase extends ItopDataTestCase
SetupUtils::tidydir($sConfFolder);
}
// - Datamodel delta files
// - Cache folder
// - Compiled folder
// We don't need to clean them as they are already by the compilation
// - Drop database
// We don't do that now, it will be done before re-creating the DB, once the metamodel is started
//----------------------------------------------------
// Prepare "$sTestEnv" environment
//----------------------------------------------------
@@ -179,7 +159,7 @@ abstract class ItopCustomDatamodelTestCase extends ItopDataTestCase
$oTestConfig->Set('db_name', $oTestConfig->Get('db_name').'_'.$sTestEnvSanitizedForDBName);
// - Compile env. based on the existing 'production' env.
$oEnvironment = new UnitTestRunTimeEnvironment($sTestEnv);
$oEnvironment = new UnitTestRunTimeEnvironment($sSourceEnv, $sTestEnv);
$oEnvironment->WriteConfigFileSafe($oTestConfig);
$oEnvironment->CompileFrom($sSourceEnv, false);
@@ -194,8 +174,7 @@ abstract class ItopCustomDatamodelTestCase extends ItopDataTestCase
// In 2.7, we can't call MetaModel::DBCreate() directly as the views creation will fail
$this->InvokeNonPublicStaticMethod(MetaModel::class, 'DBCreateTables', []);
$this->MarkEnvironmentReady();
$this->debug('Preparation of custom environment "'.$sTestEnv.'" done.');
$this->debug("Custom environment '$sTestEnv' is ready!");
}
parent::PrepareEnvironment();

View File

@@ -28,20 +28,8 @@ abstract class ItopTestCase extends TestCase
protected static $aBackupStaticProperties = [];
/** @noinspection UsingInclusionOnceReturnValueInspection avoid errors for approot includes */
protected function setUp(): void
{
$sAppRootRelPath = 'approot.inc.php';
$sDepthSeparator = '../';
for ($iDepth = 0; $iDepth < 8; $iDepth++) {
if (file_exists($sAppRootRelPath)) {
require_once $sAppRootRelPath;
break;
}
$sAppRootRelPath = $sDepthSeparator.$sAppRootRelPath;
}
$this->LoadRequiredItopFiles();
$this->LoadRequiredTestFiles();
}
@@ -68,8 +56,9 @@ abstract class ItopTestCase extends TestCase
*/
protected function LoadRequiredItopFiles(): void
{
// Empty until we actually need to require some files in the class
}
// At least make sure that the autoloader will be loaded, and that the APPROOT constant is defined
require_once __DIR__.'/../../../../approot.inc.php';
}
/**
* Overload this method to require necessary files through {@see \Combodo\iTop\Test\UnitTest\ItopTestCase::RequireOnceUnitTestFile()}

View File

@@ -1,63 +0,0 @@
<?php
declare(strict_types=1);
/*
* @copyright Copyright (C) 2010-2023 Combodo SARL
* @license http://opensource.org/licenses/AGPL-3.0
*/
namespace Combodo\iTop\Test\UnitTest\Hook;
require_once __DIR__ . '/../../../../approot.inc.php';
use PHPUnit\Runner\AfterLastTestHook;
use PHPUnit\Runner\BeforeFirstTestHook;
use utils;
/**
* Class TestsRunStartHook
*
* IMPORTANT: This will no longer work in PHPUnit 10.0 and there is no alternative for now, so we will have to migrate it when the time comes
* @link https://localheinz.com/articles/2023/02/14/extending-phpunit-with-its-new-event-system/#content-hooks-event-system
*
* @author Guillaume Lajarige <guillaume.lajarige@combodo.com>
* @package Combodo\iTop\Test\UnitTest\Hook
* @since N°6097 2.7.10 3.0.4 3.1.1
*/
class TestsRunStartHook implements BeforeFirstTestHook, AfterLastTestHook
{
/**
* Use the modification time on this file to check whereas it is newer than the requirements in a test case
*
* @return string Abs. path to a file generated when the global tests run starts.
*/
public static function GetRunStartedFileAbsPath(): string
{
// Note: This can't be put in the cache-<ENV> folder as we have multiple <ENV> running across the test cases
// We also don't want to put it in the unit tests folder as it is not supposed to be writable
return APPROOT.'data/.php-unit-tests-run-started';
}
/**
* @inheritDoc
*/
public function executeBeforeFirstTest(): void
{
// Create / change modification timestamp of file marking the beginning of the tests run
touch(static::GetRunStartedFileAbsPath());
}
/**
* @inheritDoc
*/
public function executeAfterLastTest(): void
{
// Cleanup of file marking the beginning of the tests run
if (file_exists(static::GetRunStartedFileAbsPath())) {
unlink(static::GetRunStartedFileAbsPath());
}
}
}

View File

@@ -11,6 +11,8 @@ use Combodo\iTop\Test\UnitTest\ItopCustomDatamodelTestCase;
use IssueLog;
use LogChannels;
use MFCoreModule;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use ReflectionClass;
use RunTimeEnvironment;
use utils;
@@ -27,111 +29,140 @@ use utils;
class UnitTestRunTimeEnvironment extends RunTimeEnvironment
{
/**
* @var string[]
*/
protected $aCustomDatamodelFiles = null;
/**
* @var string
*/
protected $sSourceEnv;
public function __construct($sSourceEnv, $sTargetEnv)
{
parent::__construct($sTargetEnv);
$this->sSourceEnv = $sSourceEnv;
}
public function GetEnvironment(): string
{
return $this->sFinalEnv;
}
public function IsUpToDate()
{
clearstatcache();
$fLastCompilationTime = filemtime(APPROOT.'env-'.$this->sFinalEnv);
$aModifiedFiles = [];
$this->FindFilesModifiedAfter($fLastCompilationTime, APPROOT.'datamodels/2.x', $aModifiedFiles);
$this->FindFilesModifiedAfter($fLastCompilationTime, APPROOT.'extensions', $aModifiedFiles);
$this->FindFilesModifiedAfter($fLastCompilationTime, APPROOT.'data/production-modules', $aModifiedFiles);
foreach ($this->GetCustomDatamodelFiles() as $sCustomDatamodelFile) {
if (filemtime($sCustomDatamodelFile) > $fLastCompilationTime) {
$aModifiedFiles[] = $sCustomDatamodelFile;
}
}
if (count($aModifiedFiles) > 0) {
echo "The following files have been modified after the last compilation:\n";
foreach ($aModifiedFiles as $sFile) {
echo " - $sFile\n";
}
}
return (count($aModifiedFiles) === 0);
}
/**
* @inheritDoc
*/
protected function GetMFModulesToCompile($sSourceEnv, $sSourceDir)
{
$aRet = parent::GetMFModulesToCompile($sSourceEnv, $sSourceDir);
/** @var string[] $aDeltaFiles Referential of loaded deltas. Mostly to avoid duplicates. */
$aDeltaFiles = [];
$aRelatedClasses = $this->GetClassesExtending(
ItopCustomDatamodelTestCase::class,
array(
'[\\\\/]tests[\\\\/]php-unit-tests[\\\\/]vendor[\\\\/]',
'[\\\\/]tests[\\\\/]php-unit-tests[\\\\/]unitary-tests[\\\\/]datamodels[\\\\/]2.x[\\\\/]authent-local',
));
//Combodo\iTop\Test\UnitTest\Application\ApplicationExtensionTest
//Combodo\iTop\Test\UnitTest\Application\ApplicationExtensionTest
foreach ($aRelatedClasses as $sClass) {
$oReflectionClass = new ReflectionClass($sClass);
$oReflectionMethod = $oReflectionClass->getMethod('GetDatamodelDeltaAbsPath');
// Filter on classes with an actual XML delta (eg. not \Combodo\iTop\Test\UnitTest\ItopCustomDatamodelTestCase and maybe some other deriving from a class with a delta)
if ($oReflectionMethod->isAbstract()) {
continue;
}
/** @var \Combodo\iTop\Test\UnitTest\ItopCustomDatamodelTestCase $oTestClassInstance */
$oTestClassInstance = new $sClass();
// Check test class is for desired environment
if ($oTestClassInstance->GetTestEnvironment() !== $this->sFinalEnv) {
continue;
}
// Check XML delta actually exists
$sDeltaFile = $oTestClassInstance->GetDatamodelDeltaAbsPath();
if (false === is_file($sDeltaFile)) {
$this->fail("Could not prepare '$this->sFinalEnv' as the XML delta file '$sDeltaFile' (used in $sClass) does not seem to exist");
}
// Avoid duplicates
if (in_array($sDeltaFile, $aDeltaFiles)) {
continue;
}
// Prepare fake module name for delta
$sDeltaName = preg_replace('/[^\d\w]/', '', $sDeltaFile);
// Note: We can't use \MFDeltaModule as we can't specify the ID which leads to only 1 delta being applied... In the future we might introduce a new MFXXXModule, but in the meantime it feels alright (GLA / RQU)
$oDelta = new MFCoreModule($sDeltaName, $sDeltaName, $sDeltaFile);
IssueLog::Debug('XML delta found for unit tests', static::class, [
'Unit test class' => $sClass,
'Delta file path' => $sDeltaFile,
]);
$aDeltaFiles[] = $sDeltaFile;
$aRet[$sDeltaName] = $oDelta;
foreach ($this->GetCustomDatamodelFiles() as $sDeltaFile) {
$sDeltaId = preg_replace('/[^\d\w]/', '', $sDeltaFile);
$sDeltaName = basename($sDeltaFile);
$sDeltaDir = dirname($sDeltaFile);
$oDelta = new MFCoreModule($sDeltaName, "$sDeltaDir/$sDeltaName", $sDeltaFile);
$aRet[$sDeltaId] = $oDelta;
}
return $aRet;
}
protected function GetClassesExtending (string $sExtendedClass, array $aExcludedPath = []) : array {
$aMatchingClasses = [];
$aAutoloadClassMaps =[__DIR__."/../../vendor/composer/autoload_classmap.php"];
$aClassMap = [];
$aAutoloaderErrors = [];
foreach ($aAutoloadClassMaps as $sAutoloadFile) {
$aTmpClassMap = include $sAutoloadFile;
/** @noinspection SlowArrayOperationsInLoopInspection we are getting an associative array so the documented workarounds cannot be used */
$aClassMap = array_merge($aClassMap, $aTmpClassMap);
public function GetCustomDatamodelFiles()
{
if (!is_null($this->aCustomDatamodelFiles)) {
return $this->aCustomDatamodelFiles;
}
foreach ($aClassMap as $sPHPClass => $sPHPFile) {
$bSkipped = false;
if (utils::IsNotNullOrEmptyString($sPHPFile)) {
$sPHPFile = utils::LocalPath($sPHPFile);
if ($sPHPFile !== false) {
$sPHPFile = '/'.$sPHPFile; // for regex
foreach ($aExcludedPath as $sExcludedPath) {
// Note: We use '#' as delimiters as usual '/' is often used in paths.
if ($sExcludedPath !== '' && preg_match('#'.$sExcludedPath.'#', $sPHPFile) === 1) {
$bSkipped = true;
break;
}
}
} else {
$bSkipped = true; // file not found
}
}
$this->aCustomDatamodelFiles = [];
if (!$bSkipped) {
try {
$oRefClass = new ReflectionClass($sPHPClass);
if ($oRefClass->isSubclassOf($sExtendedClass) &&
!$oRefClass->isInterface() && !$oRefClass->isAbstract() && !$oRefClass->isTrait()) {
$aMatchingClasses[] = $sPHPClass;
}
// Search for the PHP files implementing the method GetDatamodelDeltaAbsPath
// and extract the delta file path from the method
foreach(['unitary-tests', 'integration-tests'] as $sTestDir) {
// Iterate on all PHP files in subdirectories
// Note: grep is not available on Windows, so we will use the PHP Reflection API
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator(__DIR__."/../../$sTestDir")) as $oFile) {
if ($oFile->isDir()){
continue;
}
catch (Exception $e) {
if (pathinfo($oFile->getFilename(), PATHINFO_EXTENSION) !== 'php') {
continue;
}
$sFile = $oFile->getPathname();
$sContent = file_get_contents($sFile);
if (strpos($sContent, 'GetDatamodelDeltaAbsPath') === false) {
continue;
}
$sClass = '';
$aMatches = [];
if (preg_match('/namespace\s+([^;]+);/', $sContent, $aMatches)) {
$sNamespace = $aMatches[1];
$sClass = $sNamespace.'\\'.basename($sFile, '.php');
}
if (preg_match('/\s+class\s+([^ ]+)\s+/', $sContent, $aMatches)) {
$sClass = $sNamespace.'\\'.$aMatches[1];
}
if ($sClass === '') {
continue;
}
require_once $sFile;
$oReflectionClass = new ReflectionClass($sClass);
if ($oReflectionClass->isAbstract()) {
continue;
}
// Check if the class extends ItopCustomDatamodelTestCase
if (!$oReflectionClass->isSubclassOf(ItopCustomDatamodelTestCase::class)) {
continue;
}
/** @var \Combodo\iTop\Test\UnitTest\ItopCustomDatamodelTestCase $oTestClassInstance */
$oTestClassInstance = new $sClass();
if ($oTestClassInstance->GetTestEnvironment() !== $this->sFinalEnv) {
continue;
}
$sDeltaFile = $oTestClassInstance->GetDatamodelDeltaAbsPath();
if (!is_file($sDeltaFile)) {
throw new \Exception("Unknown delta file: $sDeltaFile, from test class '$sClass'");
}
if (!in_array($sDeltaFile, $this->aCustomDatamodelFiles)) {
$this->aCustomDatamodelFiles[] = $sDeltaFile;
}
}
}
return $aMatchingClasses;
return $this->aCustomDatamodelFiles;
}
private function FindFilesModifiedAfter(float $fReferenceTimestamp, string $sPathToScan, array &$aModifiedFiles)
{
if (!is_dir($sPathToScan)) {
return;
}
foreach (new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($sPathToScan)) as $oFile) {
if ($oFile->isDir()) {
continue;
}
if (filemtime($oFile->getPathname()) > $fReferenceTimestamp) {
$aModifiedFiles[] = $oFile->getPathname();
}
}
}
}