From a4764aef7d944e897f843a67d7b7c6bbcf4a4b86 Mon Sep 17 00:00:00 2001 From: Luca Tumedei Date: Tue, 6 Feb 2024 11:03:47 +0100 Subject: [PATCH] feat(IsolationSupport) add support for process isolation --- .gitignore | 3 + CHANGELOG.md | 5 + codeception.dist.yml | 1 + composer.json | 1 + config/phpstan.neon.dist | 1 + .../Symfony/Component/Process/Process.php | 13 +- src/Extension/IsolationSupport.php | 389 ++++++++++++++++++ src/MonkeyPatch/FileStreamWrapper.php | 62 ++- .../FileContentsReplacementPatcher.php | 6 +- .../Patchers/FileReplacementPatcher.php | 2 +- src/MonkeyPatch/Patchers/PatcherInterface.php | 2 +- src/TestCase/WPTestCase.php | 5 +- .../WPTestCasePHPUnitMethodsTrait.php | 78 +--- ...TestCasePHPUnitMethodsTraitPHPUnitGte8.php | 41 ++ ...PTestCasePHPUnitMethodsTraitPHPUnitLt8.php | 41 ++ src/TestCase/WPUnitTestCasePolyfillsTrait.php | 4 +- src/Utils/MonkeyPatch.php | 36 +- src/version-4-aliases.php | 4 +- .../_generated/WploaderTesterActions.php | 2 +- .../RunInSeparateProcessAnnotationTest.php | 62 +++ .../RunInSeparateProcessAttributeTest.php | 88 ++++ ...TestsInSeparateProcessesAnnotationTest.php | 32 ++ ...nTestsInSeparateProcessesAttributeTest.php | 35 ++ 23 files changed, 804 insertions(+), 109 deletions(-) create mode 100644 src/Extension/IsolationSupport.php create mode 100644 src/TestCase/WPTestCasePHPUnitMethodsTraitPHPUnitGte8.php create mode 100644 src/TestCase/WPTestCasePHPUnitMethodsTraitPHPUnitLt8.php create mode 100644 tests/wploadersuite/RunInSeparateProcessAnnotationTest.php create mode 100644 tests/wploadersuite/RunInSeparateProcessAttributeTest.php create mode 100644 tests/wploadersuite/RunTestsInSeparateProcessesAnnotationTest.php create mode 100644 tests/wploadersuite/RunTestsInSeparateProcessesAttributeTest.php diff --git a/.gitignore b/.gitignore index e9f4ffc5a..dd663b439 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,6 @@ composer.lock # Backup files and folders *.bak + +# Logs +*.log diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b7f31f93..fd6e41c1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ This project adheres to [Semantic Versioning](http://semver.org/). - The `WPTestCase` class will **not** backup globals and static attributes by default. Version `3` of wp-browser did not backup globals and static attributes by default, this change in version `4` is aligned with that behaviour to ease migration from version `3` to version `4`. +### Added + +- Restored support for the `@runInSeparateProcess` annotation for test methods. Along with it, improved support for the `@dataProvider` annotation for test methods used in conjunction with the `@runInSeparateProcess` annotation to run data provider methods at most once. +- Implemented support for the `runTestsInSeparateProcesses` annotation for test classes; supporting the `@dataProvider` annotation ro run data provider methods at most once. + ## [4.0.18] 2024-01-23; - Improve messaging and documentation around initialization and setup. diff --git a/codeception.dist.yml b/codeception.dist.yml index 38ec66c42..68f0f58cd 100644 --- a/codeception.dist.yml +++ b/codeception.dist.yml @@ -23,6 +23,7 @@ extensions: - "lucatume\\WPBrowser\\Extension\\BuiltInServerController" - "lucatume\\WPBrowser\\Extension\\ChromeDriverController" - "lucatume\\WPBrowser\\Extension\\DockerComposeController" + - "lucatume\\WPBrowser\\Extension\\IsolationSupport" config: "lucatume\\WPBrowser\\Extension\\BuiltInServerController": docroot: '%WORDPRESS_ROOT_DIR%' diff --git a/composer.json b/composer.json index 6ebfae06a..3d31d9580 100644 --- a/composer.json +++ b/composer.json @@ -55,6 +55,7 @@ "src/", "src/Deprecated" ], + "Codeception\\Extension\\": "src/Extension", "Hautelook\\Phpass\\": "includes/Hautelook/Phpass", "lucatume\\WPBrowser\\Opis\\Closure\\" : "includes/opis/closure/src" }, diff --git a/config/phpstan.neon.dist b/config/phpstan.neon.dist index ad8e22709..0a69f3426 100644 --- a/config/phpstan.neon.dist +++ b/config/phpstan.neon.dist @@ -8,3 +8,4 @@ parameters: treatPhpDocTypesAsCertain: false excludePaths: - ./../src/WordPress/Version.php # Using the WordPress version file. + - ./../src/TestCase/WPTestCasePHPUnitMethodsTraitPHPUnitLt8.php # Compat file has missing return types. diff --git a/src/Adapters/Symfony/Component/Process/Process.php b/src/Adapters/Symfony/Component/Process/Process.php index 320ffcd24..e1110e6c4 100644 --- a/src/Adapters/Symfony/Component/Process/Process.php +++ b/src/Adapters/Symfony/Component/Process/Process.php @@ -2,6 +2,8 @@ namespace lucatume\WPBrowser\Adapters\Symfony\Component\Process; +use ReflectionMethod; +use ReflectionProperty; use Symfony\Component\Process\Exception\LogicException; use Symfony\Component\Process\Pipes\PipesInterface; use Symfony\Component\Process\Process as SymfonyProcess; @@ -29,12 +31,13 @@ public function __construct( if (self::$inheritEnvironmentVariables === null) { self::$inheritEnvironmentVariables = method_exists($this, 'inheritEnvironmentVariables') && !str_contains( - (new \ReflectionMethod($this, 'inheritEnvironmentVariables'))->getDocComment(), + (string)(new ReflectionMethod($this, 'inheritEnvironmentVariables'))->getDocComment(), '@deprecated' ); } if (self::$inheritEnvironmentVariables) { + // @phpstan-ignore-next-line $this->inheritEnvironmentVariables(true); } @@ -51,7 +54,7 @@ public function getStartTime(): float throw new LogicException('Start time is only available after process start.'); } - $startTimeReflectionProperty = new \ReflectionProperty(SymfonyProcess::class, 'starttime'); + $startTimeReflectionProperty = new ReflectionProperty(SymfonyProcess::class, 'starttime'); $startTimeReflectionProperty->setAccessible(true); /** @var float $startTime */ $startTime = $startTimeReflectionProperty->getValue($this); @@ -62,7 +65,7 @@ public function getStartTime(): float public function __destruct() { if ($this->createNewConsole) { - $processPipesProperty = new \ReflectionProperty(SymfonyProcess::class, 'processPipes'); + $processPipesProperty = new ReflectionProperty(SymfonyProcess::class, 'processPipes'); $processPipesProperty->setAccessible(true); /** @var PipesInterface $processPipes */ $processPipes = $processPipesProperty->getValue($this); @@ -78,7 +81,7 @@ public function createNewConsole(): void { $this->createNewConsole = true; - $optionsReflectionProperty = new \ReflectionProperty(SymfonyProcess::class, 'options'); + $optionsReflectionProperty = new ReflectionProperty(SymfonyProcess::class, 'options'); $optionsReflectionProperty->setAccessible(true); $options = $optionsReflectionProperty->getValue($this); $options = is_array($options) ? $options : []; @@ -95,7 +98,7 @@ public static function __callStatic(string $name, array $arguments):mixed if ($name === 'fromShellCommandline') { $command = array_shift($arguments); $process = new self([], ...$arguments); // @phpstan-ignore-line - $processCommandLineProperty = new \ReflectionProperty(SymfonyProcess::class, 'commandline'); + $processCommandLineProperty = new ReflectionProperty(SymfonyProcess::class, 'commandline'); $processCommandLineProperty->setAccessible(true); $processCommandLineProperty->setValue($process, $command); diff --git a/src/Extension/IsolationSupport.php b/src/Extension/IsolationSupport.php new file mode 100644 index 000000000..9faba5971 --- /dev/null +++ b/src/Extension/IsolationSupport.php @@ -0,0 +1,389 @@ + + */ + public static array $events = [ + Events::SUITE_INIT => 'onSuiteInit', + Events::TEST_START => 'onTestStart', + Events::TEST_FAIL => 'onTestFail', + ]; + + private string $processCode = <<< PHP +\$dataName = \$this->getName(); +\$args = func_get_args(); +foreach(\$args as &\$arg){ + if(\$arg instanceof \Closure){ + \$arg = new \lucatume\WPBrowser\Opis\Closure\SerializableClosure(\$arg); + } +} +\$encodedDataSet = base64_encode(serialize(\$args)); +\$modules = \$this->getMetadata()->getCurrent('modules'); +\$wploderModuleNameInSuite = isset(\$modules['WPLoader']) ? 'WPLoader' : \lucatume\WPBrowser\Module\WPLoader::class; +\$command = [ + \lucatume\WPBrowser\Utils\Composer::binDir('codecept'), + \lucatume\WPBrowser\Command\RunOriginal::getCommandName(), + sprintf('%s:%s', codecept_relative_path('{{file}}'), '{{name}}'), + '--override', + "modules: config: {\$wploderModuleNameInSuite}: skipInstall: true", + '--ext', + 'IsolationSupport' +]; +\$process = new \lucatume\WPBrowser\Adapters\Symfony\Component\Process\Process( + \$command, + null, + [ + 'WPBROWSER_ISOLATED_RUN' => '1', + 'WPBROWSER_DATA_NAME' => \$dataName, + 'WPBROWSER_DATA_SET' => \$encodedDataSet, + 'WPBROWSER_TEST_FILE' => '{{file}}' + ], +); +\$exitCode = \$process->run(); + +if (\$exitCode !== 0) { + \$output = \$process->getOutput(); + preg_match( + '/WPBROWSER_ISOLATION_RESULT_START(.*)WPBROWSER_ISOLATION_RESULT_END/us', + \$output, + \$matches + ); + \$failure = \$matches[1] ?? null; + + if (\$failure === null) { + \$this->fail("Test failed: {\$process->getErrorOutput()}"); + } + \$serializableThrowable = unserialize(base64_decode(\$failure), ['allowed_classes' => true]); + throw(\$serializableThrowable->getThrowable()); +} +return; +PHP; + + /** + * @throws ExtensionException + */ + public function onSuiteInit(SuiteEvent $event): void + { + if ($this->isMainProcess()) { + $this->monkeyPatchTestCasesToRunInSeparateProcess($event); + return; + } + + $this->monkeyPatchTestMethodsToReplaceAnnotations(); + } + + private function isMainProcess(): bool + { + return empty($_SERVER['WPBROWSER_ISOLATED_RUN']); + } + + /** + * @throws ExtensionException + */ + protected function monkeyPatchTestCasesToRunInSeparateProcess(SuiteEvent $event): void + { + /** @var array{path: string} $settings */ + $settings = $event->getSettings(); + + /** @var Iterator $testFiles */ + $testFiles = new RegexIterator( + new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($settings['path'], FilesystemIterator::CURRENT_AS_PATHNAME) + ), + '/Test\.php$/' + ); + + foreach ($testFiles as $testFile) { + $patchedFile = $this->getPatchedFile($testFile); + + if ($patchedFile === false) { + continue; + } + + MonkeyPatch::redirectFileToFile( + $testFile, + $patchedFile, + false, + self::MAIN_PROCESS_PATCH_CONTEXT + ); + } + } + + /** + * @throws ExtensionException + */ + private function getPatchedFile(string $testFile): string|false + { + $patchedFile = MonkeyPatch::getReplacementFileName( + $testFile, + self::MAIN_PROCESS_PATCH_CONTEXT + ); + + if (is_file($patchedFile)) { + return $patchedFile; + } + + $fileContents = file_get_contents($testFile); + + if ($fileContents === false) { + throw new ExtensionException($this, "Failed to to open {$testFile} for reading."); + } + + if (str_contains($fileContents, '@runTestsInSeparateProcesses') + || str_contains($fileContents, '#[RunTestsInSeparateProcesses]') + ) { + return $this->patchFileContentsToRunTestsInSeparateProcesses($testFile, $fileContents); + } + + if (str_contains($fileContents, '@runInSeparateProcess') + || str_contains($fileContents, '#[RunInSeparateProcess]') + ) { + return $this->patchFileContentsToRunTestInSeparateProcess($testFile, $fileContents); + } + + return false; + } + + /** + * @throws ExtensionException + */ + public function patchFileContentsToRunTestsInSeparateProcesses( + string $testFile, + string $fileContents + ): string|false { + return $this->patchFileContentsToInjectSeparateProcessExecution( + '/\\s*?public\\s+function\\s+(?[^(]+)[^{]*?{/um', + $testFile, + $fileContents + ); + } + + /** + * @throws ExtensionException + */ + private function patchFileContentsToInjectSeparateProcessExecution( + string $pattern, + string $testFile, + string $fileContents + ): string|false { + // Starts with `test` OR contains the `@test` annotation OR contains the `#[Test]` attribute. + $patchedFileContents = preg_replace_callback( + $pattern, + fn($matches) => $this->injectProcessCode($matches, $testFile, $fileContents), + $fileContents + ); + + if ($patchedFileContents === $fileContents) { + return false; + } + + if ($patchedFileContents === null) { + throw new ExtensionException($this, "File contents patching failed for file {$testFile}."); + } + + $patchedFile = MonkeyPatch::getReplacementFileName( + $testFile, + self::MAIN_PROCESS_PATCH_CONTEXT + ); + + if (!file_put_contents($patchedFile, $patchedFileContents, LOCK_EX)) { + throw new ExtensionException($this, "Failed writing patch file {$patchedFile} for {$testFile}."); + } + + return $patchedFile; + } + + /** + * @param array $matches + */ + private function injectProcessCode(array $matches, string $testFile, string $fileContents): string + { + if (!$this->isTestMethod($matches['fname'], $fileContents)) { + return $matches[0]; + } + $compiledProcessCode = str_replace( + ['{{file}}', '{{name}}'], + [$testFile, $matches['fname']], + $this->processCode + ); + $processCode = preg_replace(["/[\r\n]*/", '~\\s{2,}~'], '', $compiledProcessCode); + return sprintf("%s %s", $matches[0], $processCode); + } + + private function isTestMethod(string $name, string $fileContents): bool + { + if (str_starts_with($name, 'test')) { + return true; + } + + $methodDocBlockLines = []; + $methodPos = strpos($fileContents, $name); + + if ($methodPos === false) { + return false; + } + + $input = substr($fileContents, 0, $methodPos); + // Drop the first line as it will be the `public function ...` one. + $lines = explode("\n", $input, -1); + $pattern = '/^(\\s*$|\\s*\\/\\*\\*|\\s*\\*|\\s*\\*\\s*\\/|\\s*#\\[[^]]+])/um'; + for ($i = count($lines) - 1; $i !== 0; $i--) { + $line = $lines [$i]; + if (!preg_match($pattern, $line)) { + break; + } + array_unshift($methodDocBlockLines, $line); + } + $methodDocBlock = implode("\n", $methodDocBlockLines); + return str_contains($methodDocBlock, '#[Test]') + || str_contains($methodDocBlock, '@test'); + } + + /** + * @throws ExtensionException + */ + protected function patchFileContentsToRunTestInSeparateProcess( + string $testFile, + string $fileContents + ): string|false { + $pattern = '/' + . '^\\s*' # Start of line and arbitrary number of spaces. + . '(' # Start OR group. + . '\\*\\s*@runInSeparateProcess' # @runInSeparateProcess annotation. + . '|' # OR ... + . '#\\[RunInSeparateProcess]' # [RunInSeparateProcess] attribute. + . ')' # Close OR group. + . '.*?public\\s+function\\s+(?[^(\\s]*?)\\([^{]*?{' # The function declaration until the `{`. + . '/usm'; # Multi-line pattern. + return $this->patchFileContentsToInjectSeparateProcessExecution( + $pattern, + $testFile, + $fileContents + ); + } + + /** + * @throws ExtensionException + */ + protected function monkeyPatchTestMethodsToReplaceAnnotations(): void + { + $testFile = $_SERVER['WPBROWSER_TEST_FILE']; + $fileContents = file_get_contents($testFile); + + if ($fileContents === false) { + throw new ExtensionException($this, "Failed to to open {$testFile} for reading."); + } + + $patchedTestFile = preg_replace( + [ + '/(^\\s*\\*\\s*)@dataProvider/um', + '/(^\\s+#\\[)DataProvider([^]]*?])/um', + '/(^\\s*\\*\\s*)@runInSeparateProcess/um', + '/(^\\s+#\\[)RunInSeparateProcess([^]]*?])/um', + '/(^\\s*\\*\\s*)@runTestsInSeparateProcesses/um', + '/(^\\s+#\\[)RunTestsInSeparateProcesses([^]]*?])/um' + ], + [ + '$1@dataProvidedBy', + '$1DataProvidedBy$2', + '$1@runningInSeparateProcess', + '$1RunningInSeparateProcess$2', + '$1@runningTestsInSeparateProcesses', + '$1RunningTestsInSeparateProcesses$2' + ], + $fileContents + ); + + if ($patchedTestFile === null) { + throw new ExtensionException($this, "File contents patching failed for file {$testFile}."); + } + + MonkeyPatch::redirectFileContents($testFile, $patchedTestFile, false, self::ISOLATED_PROCESS_PATCH_CONTEXT); + } + + public function onTestFail(FailEvent $failEvent): void + { + if ($this->isMainProcess()) { + return; + } + + $this->printSerializedFailure($failEvent); + } + + private function printSerializedFailure(FailEvent $failEvent): void + { + printf("\r\nWPBROWSER_ISOLATION_RESULT_START\n"); + $fail = $failEvent->getFail(); + $serializableThrowable = new SerializableThrowable($fail); + printf(base64_encode(serialize($serializableThrowable))); + printf("WPBROWSER_ISOLATION_RESULT_END\r\n"); + } + + /** + * @throws ReflectionException + */ + public function onTestStart(TestEvent $e): void + { + if ($this->isMainProcess()) { + return; + } + + $this->injectProvidedDataSet($e); + } + + /** + * @throws ReflectionException + */ + private function injectProvidedDataSet(TestEvent $e): void + { + $data = unserialize(base64_decode($_SERVER['WPBROWSER_DATA_SET']), ['allowed_classes' => true]); + + if (!is_array($data)) { + throw new \RuntimeException('Test method data must be an array, but it is ' . gettype($data)); + } + + foreach ($data as &$dataElement) { + if ($dataElement instanceof SerializableClosure) { + $dataElement = $dataElement->getClosure(); + } + } + unset($dataElement); + + $dataName = $_SERVER['WPBROWSER_DATA_NAME']; + + /** @var TestCaseWrapper $testCaseWrapper */ + $testCaseWrapper = $e->getTest(); + $testCase = $testCaseWrapper->getTestCase(); + Property::setPrivateProperties($testCase, [ + 'data' => $data, + 'dataName' => $dataName + ]); + } +} diff --git a/src/MonkeyPatch/FileStreamWrapper.php b/src/MonkeyPatch/FileStreamWrapper.php index 2a7594150..194527ccb 100644 --- a/src/MonkeyPatch/FileStreamWrapper.php +++ b/src/MonkeyPatch/FileStreamWrapper.php @@ -12,7 +12,7 @@ class FileStreamWrapper protected static bool $isRegistered = false; /** - * @var array + * @var array */ private static array $fileToPatcherMap = []; @@ -22,14 +22,18 @@ class FileStreamWrapper public $context; /** - * @var resource + * @var resource|null */ - private $fileResource; - - public static function setPatcherForFile(string $file, PatcherInterface $patcher): void - { + private $fileResource = null; + + public static function setPatcherForFile( + string $file, + PatcherInterface $patcher, + bool $redirectOpenedPath = true, + string $context = null + ): void { $fromFilePath = FS::realpath($file) ?: $file; - self::$fileToPatcherMap[$fromFilePath] = $patcher; + self::$fileToPatcherMap[$fromFilePath] = [$patcher, $redirectOpenedPath, $context ?? '']; self::register(); } @@ -116,6 +120,10 @@ public function stream_read(int $count): string|false */ public function stream_cast() { + if (!is_resource($this->fileResource)) { + throw new MonkeyPatchingException('Cannot cast a non-resource to a resource.'); + } + return $this->fileResource; } @@ -128,6 +136,10 @@ public function stream_lock(int $operation): bool public function stream_set_option(int $option, int $arg1, int $arg2): bool { + if (!is_resource($this->fileResource)) { + return false; + } + switch ($option) { case STREAM_OPTION_BLOCKING: return stream_set_blocking($this->fileResource, (bool)$arg1); @@ -146,6 +158,10 @@ public function stream_set_option(int $option, int $arg1, int $arg2): bool */ public function stream_write(string $data): int { + if (!is_resource($this->fileResource)) { + throw new MonkeyPatchingException('Cannot write to a non-resource.'); + } + $written = fwrite($this->fileResource, $data); if ($written === false) { @@ -160,7 +176,7 @@ public function stream_write(string $data): int */ public function stream_tell(): int { - $pos = ftell($this->fileResource); + $pos = is_resource($this->fileResource) ? ftell($this->fileResource) : false; if ($pos === false) { throw new MonkeyPatchingException('Could not get the position of the file pointer.'); @@ -171,16 +187,28 @@ public function stream_tell(): int public function stream_close(): bool { + if (!is_resource($this->fileResource)) { + return false; + } + return fclose($this->fileResource); } public function stream_eof(): bool { + if (!is_resource($this->fileResource)) { + return true; + } + return feof($this->fileResource); } public function stream_seek(int $offset, int $whence = SEEK_SET): bool { + if (!is_resource($this->fileResource)) { + return false; + } + return fseek($this->fileResource, $offset, $whence) === 0; } @@ -232,6 +260,10 @@ public function rmdir(string $path, int $options = null): bool */ public function stream_stat(): array|false { + if (!is_resource($this->fileResource)) { + return false; + } + $stat = fstat($this->fileResource); if ($stat === false) { @@ -304,6 +336,10 @@ public function unlink(string $path): bool public function stream_truncate(int $newSize): bool { + if (!is_resource($this->fileResource)) { + return false; + } + return ftruncate($this->fileResource, max(0, $newSize)); } @@ -360,7 +396,7 @@ public function url_stat(string $path, int $flags): array|false if (!(file_exists($path))) { if (isset(self::$fileToPatcherMap[$path])) { // Ask the patcher to provide stats. - $stat = self::$fileToPatcherMap[$path]->stat($path); + $stat = self::$fileToPatcherMap[$path][0]->stat($path); } else { $stat = false; } @@ -437,7 +473,7 @@ public function dir_opendir(string $path, int $options): bool static::register(); - return $this->fileResource !== null; + return true; } /** @@ -445,7 +481,7 @@ public function dir_opendir(string $path, int $options): bool */ private function patchFile(string $absPath): string { - $patcher = self::$fileToPatcherMap[$absPath]; + [$patcher, $redirectOpenedPath, $context] = self::$fileToPatcherMap[$absPath]; self::unregister(); // Do not use `is_file` here as it will use the cached stats: this check should be real. $fileContents = file_exists($absPath) ? file_get_contents($absPath) : ''; @@ -455,7 +491,7 @@ private function patchFile(string $absPath): string throw new MonkeyPatchingException("Could not read file $absPath contents."); } - [$fileContents, $openedPath] = $patcher->patch($fileContents, $absPath); + [$fileContents, $openedPath] = $patcher->patch($fileContents, $absPath, $context); if ($this->context !== null) { $fileResource = fopen('php://temp', 'rb+', false, $this->context); @@ -475,7 +511,7 @@ private function patchFile(string $absPath): string rewind($this->fileResource); - return $openedPath; + return $redirectOpenedPath ? $openedPath : $absPath; } /** diff --git a/src/MonkeyPatch/Patchers/FileContentsReplacementPatcher.php b/src/MonkeyPatch/Patchers/FileContentsReplacementPatcher.php index 923d0ff86..054dd9f64 100644 --- a/src/MonkeyPatch/Patchers/FileContentsReplacementPatcher.php +++ b/src/MonkeyPatch/Patchers/FileContentsReplacementPatcher.php @@ -4,6 +4,7 @@ use lucatume\WPBrowser\MonkeyPatch\MonkeyPatchingException; use lucatume\WPBrowser\Utils\Filesystem as FS; +use lucatume\WPBrowser\Utils\MonkeyPatch; class FileContentsReplacementPatcher implements PatcherInterface { @@ -14,10 +15,9 @@ public function __construct(private string $fileContents) /** * @throws MonkeyPatchingException */ - public function patch(string $fileContents, string $pathname): array + public function patch(string $fileContents, string $pathname, string $context = null): array { - $hash = md5($pathname . $fileContents) . '_' . md5($this->fileContents); - $replacementFile = FS::getTmpSubDir('_monkeypatch') . '/' . $hash . '.php'; + $replacementFile = MonkeyPatch::getReplacementFileName($pathname, $context ?? $this->fileContents); $isFile = is_file($replacementFile); if (!$isFile && !file_put_contents($replacementFile, $this->fileContents, LOCK_EX)) { diff --git a/src/MonkeyPatch/Patchers/FileReplacementPatcher.php b/src/MonkeyPatch/Patchers/FileReplacementPatcher.php index 21e9b6e3b..f4e4bacc9 100644 --- a/src/MonkeyPatch/Patchers/FileReplacementPatcher.php +++ b/src/MonkeyPatch/Patchers/FileReplacementPatcher.php @@ -13,7 +13,7 @@ public function __construct(private string $replacementFile) /** * @throws MonkeyPatchingException */ - public function patch(string $fileContents, string $pathname): array + public function patch(string $fileContents, string $pathname, string $context = null): array { $replacementFileContents = file_get_contents($this->replacementFile); diff --git a/src/MonkeyPatch/Patchers/PatcherInterface.php b/src/MonkeyPatch/Patchers/PatcherInterface.php index e23fb718e..d7680e2a7 100644 --- a/src/MonkeyPatch/Patchers/PatcherInterface.php +++ b/src/MonkeyPatch/Patchers/PatcherInterface.php @@ -7,7 +7,7 @@ interface PatcherInterface /** * @return array{string, string} */ - public function patch(string $fileContents, string $pathname): array; + public function patch(string $fileContents, string $pathname, string $context = null): array; /** * @return array{ diff --git a/src/TestCase/WPTestCase.php b/src/TestCase/WPTestCase.php index 5bd3d1730..e05b1938f 100644 --- a/src/TestCase/WPTestCase.php +++ b/src/TestCase/WPTestCase.php @@ -24,7 +24,10 @@ class WPTestCase extends Unit */ private array|null $coreTestCaseProperties = null; - protected Actor $tester; + /** + * @var Actor + */ + protected $tester; // Backup, and reset, globals between tests. protected $backupGlobals = false; diff --git a/src/TestCase/WPTestCasePHPUnitMethodsTrait.php b/src/TestCase/WPTestCasePHPUnitMethodsTrait.php index c844605a4..199e6ca79 100644 --- a/src/TestCase/WPTestCasePHPUnitMethodsTrait.php +++ b/src/TestCase/WPTestCasePHPUnitMethodsTrait.php @@ -5,81 +5,7 @@ use PHPUnit\Runner\Version; if (version_compare(Version::id(), '8.0', '<')) { - trait WPTestCasePHPUnitMethodsTrait - { - public static function setUpBeforeClass() - { - parent::setUpBeforeClass(); - self::getCoreTestCase()->set_up_before_class(); - } - - protected function setUp() - { - parent::setUp(); - - // Restores the uploads directory if removed during tests. - $uploads = wp_upload_dir(); - if (!is_dir($uploads['basedir']) - && !mkdir($uploads['basedir'], 0755, true) - && !is_dir($uploads['basedir'])) { - throw new \RuntimeException('Failed to create uploads base directory.'); - } - - $this->set_up(); //@phpstan-ignore-line magic __call - $this->backupAdditionalGlobals(); - } - - protected function tearDown() - { - $this->restoreAdditionalGlobals(); - $this->tear_down(); //@phpstan-ignore-line magic __call - parent::tearDown(); - } - - - public static function tearDownAfterClass() - { - static::tear_down_after_class(); //@phpstan-ignore-line magic __callStatic - parent::tearDownAfterClass(); - } - } + require_once __DIR__ . '/WPTestCasePHPUnitMethodsTraitPHPUnitLt8.php'; } else { - trait WPTestCasePHPUnitMethodsTrait - { - public static function setUpBeforeClass(): void - { - parent::setUpBeforeClass(); - self::getCoreTestCase()->set_up_before_class(); - } - - protected function setUp(): void - { - parent::setUp(); - - // Restores the uploads directory if removed during tests. - $uploads = wp_upload_dir(); - if (!is_dir($uploads['basedir']) - && !mkdir($uploads['basedir'], 0755, true) - && !is_dir($uploads['basedir'])) { - throw new \RuntimeException('Failed to create uploads base directory.'); - } - - $this->set_up(); //@phpstan-ignore-line magic __call - $this->backupAdditionalGlobals(); - } - - protected function tearDown(): void - { - $this->restoreAdditionalGlobals(); - $this->tear_down(); //@phpstan-ignore-line magic __call - parent::tearDown(); - } - - - public static function tearDownAfterClass(): void - { - static::tear_down_after_class(); //@phpstan-ignore-line magic __callStatic - parent::tearDownAfterClass(); - } - } + require_once __DIR__ . '/WPTestCasePHPUnitMethodsTraitPHPUnitGte8.php'; } diff --git a/src/TestCase/WPTestCasePHPUnitMethodsTraitPHPUnitGte8.php b/src/TestCase/WPTestCasePHPUnitMethodsTraitPHPUnitGte8.php new file mode 100644 index 000000000..cd2f732be --- /dev/null +++ b/src/TestCase/WPTestCasePHPUnitMethodsTraitPHPUnitGte8.php @@ -0,0 +1,41 @@ +set_up_before_class(); + } + + public static function tearDownAfterClass(): void + { + static::tear_down_after_class(); //@phpstan-ignore-line magic __callStatic + parent::tearDownAfterClass(); + } + + protected function setUp(): void + { + parent::setUp(); + + // Restores the uploads directory if removed during tests. + $uploads = wp_upload_dir(); + if (!is_dir($uploads['basedir']) + && !mkdir($uploads['basedir'], 0755, true) + && !is_dir($uploads['basedir'])) { + throw new \RuntimeException('Failed to create uploads base directory.'); + } + + $this->set_up(); //@phpstan-ignore-line magic __call + $this->backupAdditionalGlobals(); + } + + protected function tearDown(): void + { + $this->restoreAdditionalGlobals(); + $this->tear_down(); //@phpstan-ignore-line magic __call + parent::tearDown(); + } +} diff --git a/src/TestCase/WPTestCasePHPUnitMethodsTraitPHPUnitLt8.php b/src/TestCase/WPTestCasePHPUnitMethodsTraitPHPUnitLt8.php new file mode 100644 index 000000000..219ef62b3 --- /dev/null +++ b/src/TestCase/WPTestCasePHPUnitMethodsTraitPHPUnitLt8.php @@ -0,0 +1,41 @@ +set_up_before_class(); + } + + public static function tearDownAfterClass() + { + static::tear_down_after_class(); //@phpstan-ignore-line magic __callStatic + parent::tearDownAfterClass(); + } + + protected function setUp() + { + parent::setUp(); + + // Restores the uploads directory if removed during tests. + $uploads = wp_upload_dir(); + if (!is_dir($uploads['basedir']) + && !mkdir($uploads['basedir'], 0755, true) + && !is_dir($uploads['basedir'])) { + throw new \RuntimeException('Failed to create uploads base directory.'); + } + + $this->set_up(); //@phpstan-ignore-line magic __call + $this->backupAdditionalGlobals(); + } + + protected function tearDown() + { + $this->restoreAdditionalGlobals(); + $this->tear_down(); //@phpstan-ignore-line magic __call + parent::tearDown(); + } +} diff --git a/src/TestCase/WPUnitTestCasePolyfillsTrait.php b/src/TestCase/WPUnitTestCasePolyfillsTrait.php index b1b9b0874..795149d49 100644 --- a/src/TestCase/WPUnitTestCasePolyfillsTrait.php +++ b/src/TestCase/WPUnitTestCasePolyfillsTrait.php @@ -9,7 +9,7 @@ trait WPUnitTestCasePolyfillsTrait /** * @param array $arguments */ - public function __call(string $name, array $arguments) + public function __call(string $name, array $arguments) //@phpstan-ignore-line cannot be type-hinted { return TestCase::$name(...$arguments); } @@ -17,7 +17,7 @@ public function __call(string $name, array $arguments) /** * @param array $arguments */ - public static function __callStatic(string $name, array $arguments) + public static function __callStatic(string $name, array $arguments) //@phpstan-ignore-line cannot be type-hinted { return TestCase::$name(...$arguments); } diff --git a/src/Utils/MonkeyPatch.php b/src/Utils/MonkeyPatch.php index d1165c06f..123564de7 100644 --- a/src/Utils/MonkeyPatch.php +++ b/src/Utils/MonkeyPatch.php @@ -5,13 +5,23 @@ use lucatume\WPBrowser\MonkeyPatch\FileStreamWrapper; use lucatume\WPBrowser\MonkeyPatch\Patchers\FileContentsReplacementPatcher; use lucatume\WPBrowser\MonkeyPatch\Patchers\FileReplacementPatcher; +use lucatume\WPBrowser\Utils\Filesystem as FS; class MonkeyPatch { - public static function redirectFileToFile(string $fromFile, string $toFile): void - { - FileStreamWrapper::setPatcherForFile($fromFile, new FileReplacementPatcher($toFile)); + public static function redirectFileToFile( + string $fromFile, + string $toFile, + bool $redirectOpenedPath = true, + string $context = null + ): void { + FileStreamWrapper::setPatcherForFile( + $fromFile, + new FileReplacementPatcher($toFile), + $redirectOpenedPath, + $context + ); } public static function dudFile(): string @@ -19,8 +29,24 @@ public static function dudFile(): string return dirname(__DIR__) . '/MonkeyPatch/dud-file.php'; } - public static function redirectFileContents(string $fromFile, string $fileContents):void + public static function redirectFileContents( + string $fromFile, + string $fileContents, + bool $redirectOpenedPath = true, + string $context = null + ): void { + FileStreamWrapper::setPatcherForFile( + $fromFile, + new FileContentsReplacementPatcher($fileContents), + $redirectOpenedPath, + $context + ); + } + + public static function getReplacementFileName(string $pathname, string $context): string { - FileStreamWrapper::setPatcherForFile($fromFile, new FileContentsReplacementPatcher($fileContents)); + $mtime = (string)filemtime($pathname); + $hash = md5($pathname . $mtime . $context); + return FS::getTmpSubDir('_monkeypatch') . "/{$hash}.php"; } } diff --git a/src/version-4-aliases.php b/src/version-4-aliases.php index 403de816b..e2ccf9bf3 100644 --- a/src/version-4-aliases.php +++ b/src/version-4-aliases.php @@ -10,6 +10,7 @@ use lucatume\WPBrowser\Command\GenerateWPUnit; use lucatume\WPBrowser\Command\GenerateWPXMLRPC; use lucatume\WPBrowser\Extension\EventDispatcherBridge; +use lucatume\WPBrowser\Extension\IsolationSupport; use lucatume\WPBrowser\Module\WPBrowser; use lucatume\WPBrowser\Module\WPBrowserMethods; use lucatume\WPBrowser\Module\WPCLI; @@ -62,7 +63,8 @@ 'Codeception\\TestCase\\WPRestControllerTestCase' => WPRestControllerTestCase::class, 'Codeception\\TestCase\\WPRestPostTypeControllerTestCase' => WPRestPostTypeControllerTestCase::class, 'Codeception\\TestCase\\WPXMLRPCTestCase' => WPXMLRPCTestCase::class, - 'tad\\WPBrowser\\Extension\\Events' => EventDispatcherBridge::class + 'tad\\WPBrowser\\Extension\\Events' => EventDispatcherBridge::class, + 'Codeception\\Extension\\IsolationSupport' => IsolationSupport::class, ]; $countDeprecated = count($deprecated); static $hits = 0; diff --git a/tests/_support/_generated/WploaderTesterActions.php b/tests/_support/_generated/WploaderTesterActions.php index 7d2b32418..cfaa30af0 100644 --- a/tests/_support/_generated/WploaderTesterActions.php +++ b/tests/_support/_generated/WploaderTesterActions.php @@ -1,4 +1,4 @@ - [23], + 'case two' => [89] + ]; + } + + /** + * @dataProvider isolation_data_provider + * @runInSeparateProcess + */ + public function test_isolation_works(int $number): void + { + define('TEST_CONST', $number); + + $this->assertTrue(defined('TEST_CONST')); + $this->assertEquals($number, TEST_CONST); + } + + public function test_state_not_leaked_from_isolated_test(): void + { + $this->assertFalse(defined('TEST_CONST')); + } + + public function closures_provider(): Generator + { + yield 'empty return closure' => [ + fn() => null, + fn($value) => is_null($value) + ]; + + yield 'numeric return closure' => [ + fn() => 23, + fn($value) => is_int($value) + ]; + + yield 'post returning closure' => [ + fn() => static::factory()->post->create(), + fn($value) => get_post($value) instanceof WP_Post + ]; + } + + /** + * @test + * @runInSeparateProcess + * @dataProvider closures_provider + */ + public function should_correctly_serialize_closures(Closure $createCurrent, Closure $check): void + { + $this->assertTrue($check($createCurrent())); + } +} diff --git a/tests/wploadersuite/RunInSeparateProcessAttributeTest.php b/tests/wploadersuite/RunInSeparateProcessAttributeTest.php new file mode 100644 index 000000000..6d5557e70 --- /dev/null +++ b/tests/wploadersuite/RunInSeparateProcessAttributeTest.php @@ -0,0 +1,88 @@ + [23], + 'case two' => [89] + ]; + } + + #[DataProvider('isolation_data_provider')] + #[RunInSeparateProcess] + public function test_isolation_works(int $number): void + { + define('TEST_CONST', $number); + + $this->assertTrue(defined('TEST_CONST')); + $this->assertEquals($number, TEST_CONST); + } + + public function test_state_not_leaked_from_isolated_test(): void + { + $this->assertFalse(defined('TEST_CONST')); + } + + /** + * @test + */ + public function it_works(): void + { + $this->assertEquals(23, 23); + } + + /** + * @test + * @runInSeparateProcess + */ + public function it_works_2(): void + { + $this->assertEquals(23, 23); + } + + /** + * @runInSeparateProcess + * @test + */ + public function it_works_3(): void + { + $this->assertEquals(23, 23); + } + + /** + * @test + */ + #[RunInSeparateProcess] + public function it_works_4(): void + { + $this->assertEquals(23, 23); + } + + #[RunInSeparateProcess] + public function it_works_5(): void + { + $this->assertEquals(23, 23); + } + + #[RunInSeparateProcess] + /** + * @test + */ + #[DataProvider('isolation_data_provider')] + + public function it_works_6(int $number): void + { + define('TEST_CONST', $number); + + $this->assertTrue(defined('TEST_CONST')); + $this->assertEquals($number, TEST_CONST); + } +} diff --git a/tests/wploadersuite/RunTestsInSeparateProcessesAnnotationTest.php b/tests/wploadersuite/RunTestsInSeparateProcessesAnnotationTest.php new file mode 100644 index 000000000..156a95b55 --- /dev/null +++ b/tests/wploadersuite/RunTestsInSeparateProcessesAnnotationTest.php @@ -0,0 +1,32 @@ +assertEquals(23, TEST_CONST); + } + + public function test_setting_another_constant(): void + { + define('TEST_CONST_2', 89); + + $this->assertFalse(defined('TEST_CONST')); + $this->assertEquals(89, TEST_CONST_2); + } + + public function test_using_post_factory(): void + { + $post = static::factory()->post->create(); + + $this->assertInstanceOf(\WP_Post::class, get_post($post)); + } +} diff --git a/tests/wploadersuite/RunTestsInSeparateProcessesAttributeTest.php b/tests/wploadersuite/RunTestsInSeparateProcessesAttributeTest.php new file mode 100644 index 000000000..1d232778d --- /dev/null +++ b/tests/wploadersuite/RunTestsInSeparateProcessesAttributeTest.php @@ -0,0 +1,35 @@ + [23], + 'case two' => [89] + ]; + } + + #[DataProvider('isolation_data_provider')] + /** + * @dataProvider isolation_data_provider + */ + public function test_isolation_works(int $number): void + { + define('TEST_CONST', $number); + + $this->assertTrue(defined('TEST_CONST')); + $this->assertEquals($number, TEST_CONST); + } + + public function test_state_not_leaked_from_isolated_test(): void + { + $this->assertFalse(defined('TEST_CONST')); + } +}