From a2468bd5a0a9f1ac324373ccc112994871e830d7 Mon Sep 17 00:00:00 2001 From: Luca Tumedei Date: Wed, 19 Jun 2024 08:20:19 +0200 Subject: [PATCH 01/30] build(includes/core-phpunit) update from Core PHPUnit suite --- includes/core-phpunit/includes/unregister-blocks-hooks.php | 1 + 1 file changed, 1 insertion(+) diff --git a/includes/core-phpunit/includes/unregister-blocks-hooks.php b/includes/core-phpunit/includes/unregister-blocks-hooks.php index 3a4b2417c..93e57ad47 100644 --- a/includes/core-phpunit/includes/unregister-blocks-hooks.php +++ b/includes/core-phpunit/includes/unregister-blocks-hooks.php @@ -4,6 +4,7 @@ remove_action( 'init', 'register_block_core_archives' ); remove_action( 'init', 'register_block_core_avatar' ); remove_action( 'init', 'register_block_core_block' ); +remove_action( 'init', 'register_block_core_button' ); remove_action( 'init', 'register_block_core_calendar' ); remove_action( 'init', 'register_block_core_categories' ); remove_action( 'init', 'register_block_core_comment_author_name' ); From 205d48d4a2bed427bd525e839a571ace5f7a5591 Mon Sep 17 00:00:00 2001 From: Luca Tumedei Date: Wed, 19 Jun 2024 09:32:36 +0200 Subject: [PATCH 02/30] feat(PHPUnit) bare minimum PHPUnit 10/11 support The Core PHPUnit suite supports, at most, PHPUnit 9.5. This code scaffolds a first attempt at supporting PHPUnit version 10 and 11, the curent latest one, adding as little destruction as possible. This will not likely deal with all the intricacies of it, but will open the door to supporting the latest versions of Codeception and its modules supporting the latest versions of PHPUnit. --- Makefile | 1 + composer.json | 2 +- .../includes/abstract-testcase.php.patch | 48 +++++++++++++++- .../includes/abstract-testcase.php | 35 ++++++++---- src/Module/WPFilesystem.php | 1 + src/TestCase/WPTestCase.php | 57 ++++++++++++++----- .../WPBrowser/Extension/SymlinkerTest.php | 2 + 7 files changed, 117 insertions(+), 29 deletions(-) diff --git a/Makefile b/Makefile index 8f958856b..fea1505a0 100644 --- a/Makefile +++ b/Makefile @@ -13,6 +13,7 @@ clean: clean_tmp: rm -rf var/_output var/_tmp + rm -f var/_output/tmp/_monkeypatch var/_output/tmp/*.sqlite var/_output/tmp/*.sqlite_snapshot .PHONY: clean_tmp update_core_phpunit_includes: diff --git a/composer.json b/composer.json index fac292a68..1974572f9 100644 --- a/composer.json +++ b/composer.json @@ -30,7 +30,7 @@ "codeception/codeception": "^5.0", "codeception/module-asserts": "^2.0 || ^3.0", "codeception/module-phpbrowser": "^2.0 || ^3.0", - "codeception/module-webdriver": "^2.0 || ^3.0", + "codeception/module-webdriver": "^2.0 || ^3.0 || ^4.0", "codeception/module-db": "^2.0 || ^3.0", "codeception/module-filesystem": "^2.0 || ^3.0", "codeception/module-cli": "^2.0 || ^3.0", diff --git a/config/patches/core-phpunit/includes/abstract-testcase.php.patch b/config/patches/core-phpunit/includes/abstract-testcase.php.patch index 38ac46168..04164b689 100644 --- a/config/patches/core-phpunit/includes/abstract-testcase.php.patch +++ b/config/patches/core-phpunit/includes/abstract-testcase.php.patch @@ -1,5 +1,5 @@ diff --git a/includes/core-phpunit/includes/abstract-testcase.php b/includes/core-phpunit/includes/abstract-testcase.php -index f2978644..22427643 100644 +index f2978644..5c823a53 100644 --- a/includes/core-phpunit/includes/abstract-testcase.php +++ b/includes/core-phpunit/includes/abstract-testcase.php @@ -1,5 +1,7 @@ @@ -64,7 +64,49 @@ index f2978644..22427643 100644 if ( method_exists( $class, 'wpTearDownAfterClass' ) ) { call_user_func( array( $class, 'wpTearDownAfterClass' ) ); -@@ -651,7 +659,7 @@ public function expectedDeprecated() { +@@ -543,16 +551,31 @@ public function wp_die_handler( $message, $title, $args ) { + * @since 3.7.0 + */ + public function expectDeprecated() { +- if ( method_exists( $this, 'getAnnotations' ) ) { +- // PHPUnit < 9.5.0. +- $annotations = $this->getAnnotations(); +- } else { +- // PHPUnit >= 9.5.0. +- $annotations = \PHPUnit\Util\Test::parseTestMethodAnnotations( +- static::class, +- $this->getName( false ) +- ); +- } ++ if ( method_exists( $this, 'getAnnotations' ) ) { ++ // PHPUnit < 9.5.0. ++ $annotations = $this->getAnnotations(); ++ } else if( version_compare(tests_get_phpunit_version(),'10.0.0','<')) { ++ // PHPUnit >= 9.5.0 < 10.0.0. ++ $annotations = \PHPUnit\Util\Test::parseTestMethodAnnotations( ++ static::class, ++ $this->getName( false ) ++ ); ++ } else { ++ // PHPUnit >= 10.0.0. ++ if (method_exists(static::class, $this->name())) { ++ $reflectionMethod = new \ReflectionMethod(static::class, $this->name()); ++ $docBlock = \PHPUnit\Metadata\Annotation\Parser\DocBlock::ofMethod($reflectionMethod); ++ $annotations = [ ++ 'method' => $docBlock->symbolAnnotations(), ++ 'class' => [], ++ ]; ++ } else { ++ $annotations = [ ++ 'method' => null, ++ 'class' => [], ++ ]; ++ } ++ } + + foreach ( array( 'class', 'method' ) as $depth ) { + if ( ! empty( $annotations[ $depth ]['expectedDeprecated'] ) ) { +@@ -651,7 +674,7 @@ public function expectedDeprecated() { * * @since 4.2.0 */ @@ -73,7 +115,7 @@ index f2978644..22427643 100644 $this->expectedDeprecated(); } -@@ -1660,4 +1668,9 @@ public static function touch( $file ) { +@@ -1660,4 +1683,9 @@ public static function touch( $file ) { touch( $file ); } diff --git a/includes/core-phpunit/includes/abstract-testcase.php b/includes/core-phpunit/includes/abstract-testcase.php index 4064cefca..5c823a53b 100644 --- a/includes/core-phpunit/includes/abstract-testcase.php +++ b/includes/core-phpunit/includes/abstract-testcase.php @@ -551,16 +551,31 @@ public function wp_die_handler( $message, $title, $args ) { * @since 3.7.0 */ public function expectDeprecated() { - if ( method_exists( $this, 'getAnnotations' ) ) { - // PHPUnit < 9.5.0. - $annotations = $this->getAnnotations(); - } else { - // PHPUnit >= 9.5.0. - $annotations = \PHPUnit\Util\Test::parseTestMethodAnnotations( - static::class, - $this->getName( false ) - ); - } + if ( method_exists( $this, 'getAnnotations' ) ) { + // PHPUnit < 9.5.0. + $annotations = $this->getAnnotations(); + } else if( version_compare(tests_get_phpunit_version(),'10.0.0','<')) { + // PHPUnit >= 9.5.0 < 10.0.0. + $annotations = \PHPUnit\Util\Test::parseTestMethodAnnotations( + static::class, + $this->getName( false ) + ); + } else { + // PHPUnit >= 10.0.0. + if (method_exists(static::class, $this->name())) { + $reflectionMethod = new \ReflectionMethod(static::class, $this->name()); + $docBlock = \PHPUnit\Metadata\Annotation\Parser\DocBlock::ofMethod($reflectionMethod); + $annotations = [ + 'method' => $docBlock->symbolAnnotations(), + 'class' => [], + ]; + } else { + $annotations = [ + 'method' => null, + 'class' => [], + ]; + } + } foreach ( array( 'class', 'method' ) as $depth ) { if ( ! empty( $annotations[ $depth ]['expectedDeprecated'] ) ) { diff --git a/src/Module/WPFilesystem.php b/src/Module/WPFilesystem.php index 32c0843da..6d3b4bad8 100644 --- a/src/Module/WPFilesystem.php +++ b/src/Module/WPFilesystem.php @@ -377,6 +377,7 @@ public function dontSeeUploadedFileFound(string $file, int|string $date = null): if (method_exists(Assert::class, 'assertFileDoesNotExist')) { Assert::assertFileDoesNotExist($this->getUploadsPath($file, $date)); } else { + // @phpstan-ignore-next-line PHPUnit checked above. Assert::assertFileNotExists($this->getUploadsPath($file, $date)); } } diff --git a/src/TestCase/WPTestCase.php b/src/TestCase/WPTestCase.php index 9444c952a..c849ebb6c 100644 --- a/src/TestCase/WPTestCase.php +++ b/src/TestCase/WPTestCase.php @@ -93,10 +93,18 @@ class WPTestCase extends Unit */ protected $tester; - // Backup, and reset, globals between tests. + /** + * Backup, and reset, globals between tests. + * + * @var bool + */ protected $backupGlobals = false; - // A list of globals that should not be backed up: they are handled by the Core test case. + /** + * A list of globals that should not be backed up: they are handled by the Core test case. + * + * @var string[] + */ protected $backupGlobalsExcludeList = [ 'wpdb', 'wp_query', @@ -131,10 +139,18 @@ class WPTestCase extends Unit '_wpTestsBackupStaticAttributesExcludeList' ]; - // Backup, and reset, static class attributes between tests. + /** + * Backup, and reset, static class attributes between tests. + * + * @var bool + */ protected $backupStaticAttributes = false; - // A list of static attributes that should not be backed up as they are wired to explode when doing so. + /** + * A list of static attributes that should not be backed up as they are wired to explode when doing so. + * + * @var array + */ protected $backupStaticAttributesExcludeList = [ // WordPress 'WP_Block_Type_Registry' => ['instance'], @@ -156,6 +172,8 @@ class WPTestCase extends Unit /** * @param array $data + * @param string $dataName + * @throws ReflectionException */ public function __construct(?string $name = null, array $data = [], $dataName = '') { @@ -223,7 +241,8 @@ public function __construct(?string $name = null, array $data = [], $dataName = ); } - parent::__construct($name, $data, $dataName); + // @phpstan-ignore-next-line PHPUnit < 10.0.0 will require the three parameters. + parent::__construct($name ?: 'testMethod', $data, $dataName); } /** @@ -236,12 +255,13 @@ public function __construct(?string $name = null, array $data = [], $dataName = */ private static array $coreTestCaseMap = []; - private static function getCoreTestCase(): WP_UnitTestCase + private static function getCoreTestCase(?string $name = null): WP_UnitTestCase { if (isset(self::$coreTestCaseMap[static::class])) { return self::$coreTestCaseMap[static::class]; } - $coreTestCase = new class extends WP_UnitTestCase { + $methodName = $name ?: 'coreTestCase'; + $coreTestCase = new class ($methodName) extends WP_UnitTestCase { use WPUnitTestCasePolyfillsTrait; }; $coreTestCase->setCalledClass(static::class); @@ -332,7 +352,7 @@ public static function __callStatic(string $name, array $arguments): mixed */ public function __call(string $name, array $arguments): mixed { - $coreTestCase = self::getCoreTestCase(); + $coreTestCase = self::getCoreTestCase($name); $reflectionMethod = new ReflectionMethod($coreTestCase, $name); $reflectionMethod->setAccessible(true); return $reflectionMethod->invokeArgs($coreTestCase, $arguments); @@ -373,15 +393,11 @@ public function __get(string $name): mixed return $this->{$name} ?? null; } - $coreTestCase = self::getCoreTestCase(); + $coreTestCase = self::getCoreTestCase('__get'); $reflectionProperty = new ReflectionProperty($coreTestCase, $name); $reflectionProperty->setAccessible(true); $value = $reflectionProperty->getValue($coreTestCase); -// if (is_array($value)) { -// return new ArrayReflectionPropertyAccessor($reflectionProperty, $coreTestCase); -// } - return $value; } @@ -396,7 +412,7 @@ public function __set(string $name, mixed $value): void return; } - $coreTestCase = self::getCoreTestCase(); + $coreTestCase = self::getCoreTestCase('__set'); $reflectionProperty = new ReflectionProperty($coreTestCase, $name); $reflectionProperty->setAccessible(true); $reflectionProperty->setValue($coreTestCase, $value); @@ -411,9 +427,20 @@ public function __isset(string $name): bool return isset($this->{$name}); } - $coreTestCase = self::getCoreTestCase(); + $coreTestCase = self::getCoreTestCase('__isset'); $reflectionProperty = new ReflectionProperty($coreTestCase, $name); $reflectionProperty->setAccessible(true); return $reflectionProperty->isInitialized($coreTestCase); } + + public function getName(bool $withDataSet = true): string + { + if (method_exists(parent::class, 'getName')) { + // PHPUnit < 10.0.0. + return parent::getName($withDataSet); + } + + // PHPUnit >= 10.0.0. + return $withDataSet ? $this->nameWithDataSet() : $this->name(); + } } diff --git a/tests/unit/lucatume/WPBrowser/Extension/SymlinkerTest.php b/tests/unit/lucatume/WPBrowser/Extension/SymlinkerTest.php index 634d06596..558388470 100644 --- a/tests/unit/lucatume/WPBrowser/Extension/SymlinkerTest.php +++ b/tests/unit/lucatume/WPBrowser/Extension/SymlinkerTest.php @@ -8,6 +8,7 @@ use Codeception\Test\Unit; use lucatume\WPBrowser\Extension\Symlinker; use lucatume\WPBrowser\Tests\Traits\LoopIsolation; +use lucatume\WPBrowser\Tests\Traits\TmpFilesCleanup; use lucatume\WPBrowser\Utils\Filesystem as FS; use lucatume\WPBrowser\WordPress\Installation; use PHPUnit\Framework\Assert; @@ -15,6 +16,7 @@ class SymlinkerTest extends Unit { use LoopIsolation; + use TmpFilesCleanup; public function test_exists(): void { From 358ea0a37f0e68c84c1f40267e1d0e452c9325a8 Mon Sep 17 00:00:00 2001 From: Luca Tumedei Date: Wed, 17 Jul 2024 08:41:50 +0200 Subject: [PATCH 03/30] fix(src) PHPStan reported issues --- src/Module/WPFilesystem.php | 1 - src/TestCase/WPTestCase.php | 5 ++--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/Module/WPFilesystem.php b/src/Module/WPFilesystem.php index 6d3b4bad8..32c0843da 100644 --- a/src/Module/WPFilesystem.php +++ b/src/Module/WPFilesystem.php @@ -377,7 +377,6 @@ public function dontSeeUploadedFileFound(string $file, int|string $date = null): if (method_exists(Assert::class, 'assertFileDoesNotExist')) { Assert::assertFileDoesNotExist($this->getUploadsPath($file, $date)); } else { - // @phpstan-ignore-next-line PHPUnit checked above. Assert::assertFileNotExists($this->getUploadsPath($file, $date)); } } diff --git a/src/TestCase/WPTestCase.php b/src/TestCase/WPTestCase.php index c849ebb6c..607220788 100644 --- a/src/TestCase/WPTestCase.php +++ b/src/TestCase/WPTestCase.php @@ -149,7 +149,7 @@ class WPTestCase extends Unit /** * A list of static attributes that should not be backed up as they are wired to explode when doing so. * - * @var array + * @var array> */ protected $backupStaticAttributesExcludeList = [ // WordPress @@ -241,7 +241,6 @@ public function __construct(?string $name = null, array $data = [], $dataName = ); } - // @phpstan-ignore-next-line PHPUnit < 10.0.0 will require the three parameters. parent::__construct($name ?: 'testMethod', $data, $dataName); } @@ -440,7 +439,7 @@ public function getName(bool $withDataSet = true): string return parent::getName($withDataSet); } - // PHPUnit >= 10.0.0. + // @phpstan-ignore-next-line PHPUnit >= 10.0.0. return $withDataSet ? $this->nameWithDataSet() : $this->name(); } } From 13f393e224838229c18ccf7fa1bb53a6e12600b8 Mon Sep 17 00:00:00 2001 From: Luca Tumedei Date: Wed, 17 Jul 2024 08:44:57 +0200 Subject: [PATCH 04/30] typos(docs) fix --- docs/modules/WPLoader.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/modules/WPLoader.md b/docs/modules/WPLoader.md index be9d825fa..545bbc9a2 100644 --- a/docs/modules/WPLoader.md +++ b/docs/modules/WPLoader.md @@ -270,7 +270,7 @@ your site to run tests using the default configuration based on PHP built-in ser ## Configuration with loadOnly: true -The module will load WordPress from the location specified by the `wpRootFodler` parameter, relying +The module will load WordPress from the location specified by the `wpRootFolder` parameter, relying on [the WPDb module](WPDb.md) to manage the database state. When used in this mode, the module supports the following configuration parameters: From e9056ec130b3020d55e288460575ce4f7f26bed8 Mon Sep 17 00:00:00 2001 From: Luca Tumedei Date: Wed, 17 Jul 2024 09:50:51 +0200 Subject: [PATCH 05/30] fix(StubClassFactory) support PHPUnit >= 10 --- tests/_support/StubClassFactory.php | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/tests/_support/StubClassFactory.php b/tests/_support/StubClassFactory.php index 255b0c136..0bf5a1b15 100644 --- a/tests/_support/StubClassFactory.php +++ b/tests/_support/StubClassFactory.php @@ -5,6 +5,8 @@ use Codeception\Stub; use Exception; use lucatume\WPBrowser\Utils\Property; +use PHPUnit\Event\Runtime\PHPUnit; +use PHPUnit\Runner\Version; use ReflectionException; use ReflectionMethod; @@ -35,11 +37,25 @@ public static function connectInvocationMocker(object $mock): void $mockClassName = get_class($mock); [$class, $parameters] = self::$stubParametersByClassName[$mockClassName]; $stub = Stub::makeEmpty($class, $parameters); - Property::setPrivateProperties($mock, [ - '__phpunit_originalObject' => Property::readPrivate($stub, '__phpunit_originalObject'), - '__phpunit_returnValueGeneration' => Property::readPrivate($stub, '__phpunit_returnValueGeneration'), - '__phpunit_invocationMocker' => Property::readPrivate($stub, '__phpunit_invocationMocker'), - ]); + $phpuniStateProperty = null; + + try { + $phpuniStateProperty = Property::readPrivate($stub, '__phpunit_state'); + } catch (\Throwable $t) { + // PHPUnit < 10.0.0. + } + + if ($phpuniStateProperty) { + // PHPUnit >= 10.0.0. + Property::setPrivateProperties($mock, ['__phpunit_state' => $phpuniStateProperty]); + } else { + Property::setPrivateProperties($mock, [ + '__phpunit_originalObject' => Property::readPrivate($stub, '__phpunit_originalObject'), + '__phpunit_returnValueGeneration' => Property::readPrivate($stub, '__phpunit_returnValueGeneration'), + '__phpunit_invocationMocker' => Property::readPrivate($stub, '__phpunit_invocationMocker'), + ]); + } + unset($stub); } From 044d62a126cd7dc6a85fbce010fb6be9cf70deda Mon Sep 17 00:00:00 2001 From: Luca Tumedei Date: Wed, 17 Jul 2024 10:25:55 +0200 Subject: [PATCH 06/30] fix(StubClassFactory) handle PHPUnit version 10 differently from others --- tests/_support/StubClassFactory.php | 36 ++++++++++++++++++++--------- 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/tests/_support/StubClassFactory.php b/tests/_support/StubClassFactory.php index 0bf5a1b15..0ebb8c234 100644 --- a/tests/_support/StubClassFactory.php +++ b/tests/_support/StubClassFactory.php @@ -12,6 +12,7 @@ class StubClassFactory { + private static ?int $phpunitVersion = null; private static string $classTemplate = 'class %1$s extends %2$s { public function __construct(%3$s) @@ -29,6 +30,17 @@ public static function tearDown(): void self::$constructorAssertions = []; } + private static function getPHPUnitVersion(): int + { + if (self::$phpunitVersion === null) { + self::$phpunitVersion = class_exists('PHPUnit\Runner\Version') ? + (int)\PHPUnit\Runner\Version::id() + : (int)\PHPUnit_Runner_Version::id(); + } + + return self::$phpunitVersion; + } + /** * @throws Exception */ @@ -37,23 +49,25 @@ public static function connectInvocationMocker(object $mock): void $mockClassName = get_class($mock); [$class, $parameters] = self::$stubParametersByClassName[$mockClassName]; $stub = Stub::makeEmpty($class, $parameters); - $phpuniStateProperty = null; - - try { - $phpuniStateProperty = Property::readPrivate($stub, '__phpunit_state'); - } catch (\Throwable $t) { - // PHPUnit < 10.0.0. - } + $phpunitVersion = self::getPHPUnitVersion(); - if ($phpuniStateProperty) { - // PHPUnit >= 10.0.0. - Property::setPrivateProperties($mock, ['__phpunit_state' => $phpuniStateProperty]); - } else { + if ($phpunitVersion < 10) { Property::setPrivateProperties($mock, [ '__phpunit_originalObject' => Property::readPrivate($stub, '__phpunit_originalObject'), '__phpunit_returnValueGeneration' => Property::readPrivate($stub, '__phpunit_returnValueGeneration'), '__phpunit_invocationMocker' => Property::readPrivate($stub, '__phpunit_invocationMocker'), ]); + } elseif ($phpunitVersion === 10) { + Property::setPrivateProperties($mock, [ + '__phpunit_returnValueGeneration' => Property::readPrivate($stub, '__phpunit_returnValueGeneration'), + '__phpunit_invocationMocker' => Property::readPrivate($stub, '__phpunit_invocationMocker'), + ]); + } else { + // PHPUnit >= 10.0.0. + Property::setPrivateProperties( + $mock, + ['__phpunit_state' => Property::readPrivate($stub, '__phpunit_state')] + ); } unset($stub); From ed1ae51c844b8fa66ce778a8d115e7165e8527e0 Mon Sep 17 00:00:00 2001 From: Luca Tumedei Date: Fri, 19 Jul 2024 09:05:17 +0200 Subject: [PATCH 07/30] refactor(Utils/Property) reduce looping --- src/Utils/Property.php | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/Utils/Property.php b/src/Utils/Property.php index 21db30464..0a46e70b2 100644 --- a/src/Utils/Property.php +++ b/src/Utils/Property.php @@ -47,14 +47,19 @@ public static function readPrivate(object|string $object, string $property): mix * @param object|null $object $object The object to set the properties of, `null` if using a class. * @param string $class The object class to set the properties for. * @param array $props A map of the names and values of the properties to set. + * @param array|null $propsToSet An array, modified by reference, of the properties left to set. * * @return object The updated object. * * @throws ReflectionException If there's an issue reflecting on the object or its properties. * @throws InvalidArgumentException If the class does not exists or the constructor parameters are missing. */ - public static function setPropertiesForClass(?object $object, string $class, array $props): object - { + public static function setPropertiesForClass( + ?object $object, + string $class, + array $props, + array &$propsToSet = null + ): object { if (!class_exists($class)) { throw new InvalidArgumentException( sprintf('Class "%s" does not exists', $class) @@ -87,6 +92,9 @@ public static function setPropertiesForClass(?object $object, string $class, arr if (isset($props[$property->name])) { $property->setAccessible(true); $property->setValue($object, $props[$property->name]); + if (is_array($propsToSet)) { + unset($propsToSet[array_search($property->name, $propsToSet, true)]); + } } } @@ -113,9 +121,10 @@ public static function setPrivateProperties(object|string $object, array $props) $class = $object::class; } + $propsToSet = array_keys($props); do { - $object = self::setPropertiesForClass($object, $class, $props); + $object = self::setPropertiesForClass($object, $class, $props, $propsToSet); $class = get_parent_class($class); - } while ($class); + } while ($class && $propsToSet); } } From 1d3b76869a8701feb60bd04d1a6bc9b98ae7d59a Mon Sep 17 00:00:00 2001 From: Luca Tumedei Date: Mon, 29 Jul 2024 08:59:49 +0200 Subject: [PATCH 08/30] refactor(WPTestCase) update to support PHPUnit 10 --- composer.json | 1 + src/TestCase/WPTestCase.php | 90 ++++-- ...rolTestCaseOverridingTestCasePHPUnit10.php | 33 +++ tests/_support/StubClassFactory.php | 173 +++++++++--- .../lucatume/WPBrowser/Command/RunAllTest.php | 2 + .../Extension/DockerComposeControllerTest.php | 10 +- .../WPBrowser/Module/WPFilesystemTest.php | 18 +- .../WPBrowser/Module/WPLoaderTest.php | 264 ++++++++++++++---- 8 files changed, 450 insertions(+), 141 deletions(-) create mode 100644 tests/_data/files/BackupControlTestCaseOverridingTestCasePHPUnit10.php diff --git a/composer.json b/composer.json index 7dd4a581e..3a0fdd003 100644 --- a/composer.json +++ b/composer.json @@ -27,6 +27,7 @@ "ext-curl": "*", "ext-zip": "*", "composer-runtime-api": "^2.2", + "phpunit/phpunit": "<=12.0.0", "codeception/codeception": "^5.0", "codeception/module-asserts": "^2.0 || ^3.0", "codeception/module-phpbrowser": "^2.0 || ^3.0", diff --git a/src/TestCase/WPTestCase.php b/src/TestCase/WPTestCase.php index 607220788..f3f1e8e63 100644 --- a/src/TestCase/WPTestCase.php +++ b/src/TestCase/WPTestCase.php @@ -8,10 +8,12 @@ use Codeception\Module; use Codeception\Test\Unit; use lucatume\WPBrowser\Module\WPQueries; +use ReflectionClass; use ReflectionException; use ReflectionMethod; use ReflectionProperty; use WP_UnitTestCase; +use PHPUnit\Runner\Version as PHPUnitVersion; /** * @method static commit_transaction() @@ -140,7 +142,7 @@ class WPTestCase extends Unit ]; /** - * Backup, and reset, static class attributes between tests. + * Backup, and reset, static class attributes between tests for PHPUnit < 10.0.0. * * @var bool */ @@ -148,6 +150,7 @@ class WPTestCase extends Unit /** * A list of static attributes that should not be backed up as they are wired to explode when doing so. + * PHPUnit < 10.0.0. * * @var array> */ @@ -170,17 +173,11 @@ class WPTestCase extends Unit private ?float $requestTimeFloat = null; private ?int $requestTime = null; - /** - * @param array $data - * @param string $dataName - * @throws ReflectionException - */ - public function __construct(?string $name = null, array $data = [], $dataName = '') + private function initBackupGlobalsProperties():void { global $_wpTestsBackupGlobals, - $_wpTestsBackupGlobalsExcludeList, - $_wpTestsBackupStaticAttributes, - $_wpTestsBackupStaticAttributesExcludeList; + $_wpTestsBackupGlobalsExcludeList; + $phpunitVersion = (int)PHPUnitVersion::series(); $backupGlobalsReflectionProperty = new ReflectionProperty($this, 'backupGlobals'); $backupGlobalsReflectionProperty->setAccessible(true); @@ -189,12 +186,13 @@ public function __construct(?string $name = null, array $data = [], $dataName = $this->backupGlobals = $_wpTestsBackupGlobals; } - if (property_exists($this, 'backupGlobalsExcludeList')) { - $backupGlobalsExcludeListReflectionProperty = new ReflectionProperty($this, 'backupGlobalsExcludeList'); - } else { + if ($phpunitVersion < 9) { // Older versions of PHPUnit. $backupGlobalsExcludeListReflectionProperty = new ReflectionProperty($this, 'backupGlobalsBlacklist'); + } else { + $backupGlobalsExcludeListReflectionProperty = new ReflectionProperty($this, 'backupGlobalsExcludeList'); } + $backupGlobalsExcludeListReflectionProperty->setAccessible(true); $isDefinedInThis = $backupGlobalsExcludeListReflectionProperty->getDeclaringClass() ->getName() !== WPTestCase::class; @@ -207,8 +205,16 @@ public function __construct(?string $name = null, array $data = [], $dataName = $_wpTestsBackupGlobalsExcludeList ); } + } + + private function initBackupStaticPropertiesForPHPUnit( + string $backupStaticAttributesPropertyName, + string $backupStaticAttributesExcludeListPropertyName + ): void { + global $_wpTestsBackupStaticAttributes, + $_wpTestsBackupStaticAttributesExcludeList; - $backupStaticAttributesReflectionProperty = new ReflectionProperty($this, 'backupStaticAttributes'); + $backupStaticAttributesReflectionProperty = new ReflectionProperty($this, $backupStaticAttributesPropertyName); $backupStaticAttributesReflectionProperty->setAccessible(true); $isDefinedInThis = $backupStaticAttributesReflectionProperty->getDeclaringClass() ->getName() !== WPTestCase::class; @@ -216,18 +222,10 @@ public function __construct(?string $name = null, array $data = [], $dataName = $this->backupStaticAttributes = $_wpTestsBackupStaticAttributes; } - if (property_exists($this, 'backupStaticAttributesExcludeList')) { - $backupStaticAttributesExcludeListReflectionProperty = new ReflectionProperty( - $this, - 'backupStaticAttributesExcludeList' - ); - } else { - // Older versions of PHPUnit. - $backupStaticAttributesExcludeListReflectionProperty = new ReflectionProperty( - $this, - 'backupStaticAttributesBlacklist' - ); - } + $backupStaticAttributesExcludeListReflectionProperty = new ReflectionProperty( + $this, + $backupStaticAttributesExcludeListPropertyName + ); $backupStaticAttributesExcludeListReflectionProperty->setAccessible(true); $isDefinedInThis = $backupStaticAttributesExcludeListReflectionProperty->getDeclaringClass() ->getName() !== WPTestCase::class; @@ -240,6 +238,44 @@ public function __construct(?string $name = null, array $data = [], $dataName = $_wpTestsBackupStaticAttributesExcludeList ); } + } + + private function initBackupStaticPropertiesForPHPUnitGte10(): void + { + global $_wpTestsBackupStaticAttributes, + $_wpTestsBackupStaticAttributesExcludeList; + + $backupStaticProperties = property_exists($this, 'backupStaticProperties') ? + $this->backupStaticProperties : + $_wpTestsBackupStaticAttributes; + // @phpstan-ignore-next-line exists in PHPUnit >= 10.0.0 + $this->setBackupStaticProperties($backupStaticProperties); + + $backupStaticPropertiesExcludeList = property_exists($this, 'backupStaticPropertiesExcludeList') ? + $this->backupStaticPropertiesExcludeList : + array_merge($this->backupStaticAttributesExcludeList, $_wpTestsBackupStaticAttributesExcludeList); + // @phpstan-ignore-next-line exists in PHPUnit >= 10.0.0 + $this->setBackupStaticPropertiesExcludeList($backupStaticPropertiesExcludeList); + } + + /** + * @param array $data + * @param string $dataName + * @throws ReflectionException + */ + public function __construct(?string $name = null, array $data = [], $dataName = '') + { + $this->initBackupGlobalsProperties(); + + $phpunitVersion = (int)PHPUnitVersion::series(); + + if ($phpunitVersion < 9) { + $this->initBackupStaticPropertiesForPHPUnit('backupStaticAttributes', 'backupStaticAttributesBlacklist'); + } elseif ($phpunitVersion === 9) { + $this->initBackupStaticPropertiesForPHPUnit('backupStaticAttributes', 'backupStaticAttributesExcludeList'); + } else { + $this->initBackupStaticPropertiesForPHPUnitGte10(); + } parent::__construct($name ?: 'testMethod', $data, $dataName); } @@ -376,7 +412,7 @@ private function isCoreTestCaseProperty(string $name): bool if ($this->coreTestCaseProperties === null) { $this->coreTestCaseProperties = array_map( static fn(ReflectionProperty $p) => $p->getName(), - (new \ReflectionClass(self::getCoreTestCase()))->getProperties() + (new ReflectionClass(self::getCoreTestCase()))->getProperties() ); } diff --git a/tests/_data/files/BackupControlTestCaseOverridingTestCasePHPUnit10.php b/tests/_data/files/BackupControlTestCaseOverridingTestCasePHPUnit10.php new file mode 100644 index 000000000..1a82d83e8 --- /dev/null +++ b/tests/_data/files/BackupControlTestCaseOverridingTestCasePHPUnit10.php @@ -0,0 +1,33 @@ +_before(); + } + + public function testBackupGlobalsIsFalse(): void + { + $this->assertFalse($this->backupGlobals); + } + + public function testWillAlterStoreStaticAttribute(): void + { + BackupControlTestCaseOverridingStore::$staticAttribute = 'updated_value'; + $this->assertTrue(true); // Useless assertion to avoid the test to be marked as risky. } + } +} diff --git a/tests/_support/StubClassFactory.php b/tests/_support/StubClassFactory.php index 0ebb8c234..c7d1b01de 100644 --- a/tests/_support/StubClassFactory.php +++ b/tests/_support/StubClassFactory.php @@ -5,74 +5,129 @@ use Codeception\Stub; use Exception; use lucatume\WPBrowser\Utils\Property; -use PHPUnit\Event\Runtime\PHPUnit; -use PHPUnit\Runner\Version; +use PHPUnit\Runner\Version as PHPUnitVersion; use ReflectionException; use ReflectionMethod; class StubClassFactory { - private static ?int $phpunitVersion = null; - private static string $classTemplate = 'class %1$s extends %2$s + private static string $classTemplatePhpUnitLt10 = 'class %1$s extends %2$s { public function __construct(%3$s) { - $this->_stub = %4$s::connectInvocationMocker($this); + $this->__phpunit_originalObject = %4$s::getPhpunitOriginalObject(%1$s"); + $this->__phpunit_returnValueGeneration = %4$s::getPhpunitReturnValueGeneration("%1$s"); + $this->__phpunit_invocationMocker = %4$s::getPhpunitInvocationMocker("%1$s"); %4$s::assertConstructorConditions("%1$s", func_get_args()); + %4$s::setMockForClassName("%1$s", $this); } }'; - private static array $stubParametersByClassName = []; + private static string $classTemplatePhpUnitEq10 = 'class %1$s extends %2$s +{ + public function __construct(%3$s) + { + $this->__phpunit_returnValueGeneration = %4$s::getPhpunitReturnValueGeneration("%1$s"); + $this->__phpunit_invocationMocker = %4$s::getPhpunitInvocationMocker("%1$s"); + %4$s::assertConstructorConditions("%1$s", func_get_args()); + %4$s::setMockForClassName("%1$s", $this); + } +}'; + private static string $classTemplatePhpUnitGt10 = 'class %1$s extends %2$s +{ + use \PHPUnit\Framework\MockObject\StubApi; + + public function __construct(%3$s) + { + $this->__phpunit_state = %4$s::getPHPUnitStateObject("%1$s"); + %4$s::assertConstructorConditions("%1$s", func_get_args()); + %4$s::setMockForClassName("%1$s", $this); + } +}'; + /** + * @var array + */ private static array $constructorAssertions = []; + /** + * @var array + */ + private static mixed $stubByClassName = []; + /** + * @var array + */ + private static array $mockByClassName = []; + + public static function setMockForClassName(string $mockClassName, object $mock): void + { + self::$mockByClassName[$mockClassName] = $mock; + } + public static function tearDown(): void { - self::$stubParametersByClassName = []; + self::$stubByClassName = []; self::$constructorAssertions = []; + self::$mockByClassName = []; } - private static function getPHPUnitVersion(): int + /** + * @throws ReflectionException + */ + public static function getPhpunitOriginalObject(string $mockClassName): object { - if (self::$phpunitVersion === null) { - self::$phpunitVersion = class_exists('PHPUnit\Runner\Version') ? - (int)\PHPUnit\Runner\Version::id() - : (int)\PHPUnit_Runner_Version::id(); + $value = Property::readPrivate(self::$stubByClassName[$mockClassName], '__phpunit_originalObject'); + + if (!is_object($value)) { + throw new ReflectionException('No original object found for ' . $mockClassName); } - return self::$phpunitVersion; + return $value; } /** - * @throws Exception + * @throws ReflectionException */ - public static function connectInvocationMocker(object $mock): void + public static function getPhpunitReturnValueGeneration(string $mockClassName): object { - $mockClassName = get_class($mock); - [$class, $parameters] = self::$stubParametersByClassName[$mockClassName]; - $stub = Stub::makeEmpty($class, $parameters); - $phpunitVersion = self::getPHPUnitVersion(); + $value = Property::readPrivate(self::$stubByClassName[$mockClassName], '__phpunit_returnValueGeneration'); - if ($phpunitVersion < 10) { - Property::setPrivateProperties($mock, [ - '__phpunit_originalObject' => Property::readPrivate($stub, '__phpunit_originalObject'), - '__phpunit_returnValueGeneration' => Property::readPrivate($stub, '__phpunit_returnValueGeneration'), - '__phpunit_invocationMocker' => Property::readPrivate($stub, '__phpunit_invocationMocker'), - ]); - } elseif ($phpunitVersion === 10) { - Property::setPrivateProperties($mock, [ - '__phpunit_returnValueGeneration' => Property::readPrivate($stub, '__phpunit_returnValueGeneration'), - '__phpunit_invocationMocker' => Property::readPrivate($stub, '__phpunit_invocationMocker'), - ]); - } else { - // PHPUnit >= 10.0.0. - Property::setPrivateProperties( - $mock, - ['__phpunit_state' => Property::readPrivate($stub, '__phpunit_state')] - ); + if (!is_object($value)) { + throw new ReflectionException('No return value generation found for ' . $mockClassName); } - unset($stub); + return $value; } + /** + * @throws ReflectionException + */ + public static function getPhpunitInvocationMocker(string $mockClassName): object + { + $value = Property::readPrivate(self::$stubByClassName[$mockClassName], '__phpunit_invocationMocker'); + + if (!is_object($value)) { + throw new ReflectionException('No invocation mocker found for ' . $mockClassName); + } + + return $value; + } + + /** + * @throws ReflectionException + */ + public static function getPHPUnitStateObject(string $mockClassName): object + { + $value = Property::readPrivate(self::$stubByClassName[$mockClassName], '__phpunit_state'); + + if (!is_object($value)) { + throw new ReflectionException('No PHPUnit state object found for ' . $mockClassName); + } + + return $value; + } + + /** + * @param array $args + */ public static function assertConstructorConditions(string $mockClassName, array $args): void { if (!isset(self::$constructorAssertions[$mockClassName])) { @@ -82,17 +137,22 @@ public static function assertConstructorConditions(string $mockClassName, array } /** - * @throws ReflectionException + * @param class-string $class + * @param array $parameters + * * @throws Exception + * @throws ReflectionException */ public static function makeEmptyClass(string $class, array $parameters): string { $classBasename = basename(str_replace('\\', '/', $class)); $mockClassName = $classBasename . '_' . substr(md5(microtime()), 0, 8); $constructorStringDump = (new ReflectionMethod($class, '__construct'))->__toString(); - preg_match_all('/Parameter #\\d+ \\[ <(?:optional|required)> (?.*) ]/u', + preg_match_all( + '/Parameter #\\d+ \\[ <(?:optional|required)> (?.*) ]/u', $constructorStringDump, - $matches); + $matches + ); $constructorParams = ''; if (!empty($matches)) { $constructorParams = implode( @@ -108,17 +168,42 @@ public static function makeEmptyClass(string $class, array $parameters): string unset($parameters['__construct']); } + foreach ($parameters as &$value) { + if ($value === '__itself') { + $value = fn() => self::getMockByClassName($mockClassName); + } + } + $codeceptionStub = Stub::makeEmpty($class, $parameters); - $classCode = sprintf(self::$classTemplate, + $phpunitVersion = (int)PHPUnitVersion::series(); + if ($phpunitVersion < 10) { + $classTemplate = self::$classTemplatePhpUnitLt10; + } elseif ($phpunitVersion === 10) { + $classTemplate = self::$classTemplatePhpUnitEq10; + } else { + $classTemplate = self::$classTemplatePhpUnitGt10; + } + + $classCode = sprintf( + $classTemplate, $mockClassName, get_class($codeceptionStub), $constructorParams, - self::class); - unset($codeceptionStub); + self::class + ); + eval($classCode); - self::$stubParametersByClassName[$mockClassName] = [$class, $parameters]; + self::$stubByClassName[$mockClassName] = $codeceptionStub; return $mockClassName; } + + /** + * @param string $mockClassName + */ + private static function getMockByClassName(string $mockClassName): object + { + return self::$mockByClassName[$mockClassName]; + } } diff --git a/tests/unit/lucatume/WPBrowser/Command/RunAllTest.php b/tests/unit/lucatume/WPBrowser/Command/RunAllTest.php index 4720284d5..37b4caac2 100644 --- a/tests/unit/lucatume/WPBrowser/Command/RunAllTest.php +++ b/tests/unit/lucatume/WPBrowser/Command/RunAllTest.php @@ -48,6 +48,7 @@ public function should_invoke_codecept_bin_once_for_each_suite(): void }, 'getIterator' => fn() => yield from ["Running suite\n", "Done\n"], 'isSuccessful' => fn() => true, + 'setTimeout' => '__itself', ]; $this->setClassMock(Process::class, $this->makeEmptyClass(Process::class, $mockParams)); $this->setMethodReturn(Configuration::class, 'suites', ['suite-1', 'suite-2', 'suite-3']); @@ -84,6 +85,7 @@ public function should_return_1_if_any_suite_fails(int $failingSuite, string $ex 'isSuccessful' => function () use ($failingSuite, &$currentSuite) { return $currentSuite++ !== $failingSuite; }, + 'setTimeout' => '__itself', ]; $this->setClassMock(Process::class, $this->makeEmptyClass(Process::class, $mockParams)); $this->setMethodReturn(Configuration::class, 'suites', ['suite-1', 'suite-2', 'suite-3']); diff --git a/tests/unit/lucatume/WPBrowser/Extension/DockerComposeControllerTest.php b/tests/unit/lucatume/WPBrowser/Extension/DockerComposeControllerTest.php index 3885f853b..183e11e05 100644 --- a/tests/unit/lucatume/WPBrowser/Extension/DockerComposeControllerTest.php +++ b/tests/unit/lucatume/WPBrowser/Extension/DockerComposeControllerTest.php @@ -150,7 +150,8 @@ public function should_up_stack_correctly(): void $this->makeEmptyClass(Process::class, [ '__construct' => static function ($command, ...$args) use (&$constructCommands) { $constructCommands[] = $command; - } + }, + 'mustRun' => '__itself' ]) ); @@ -227,6 +228,7 @@ public function should_correctly_handle_stack_lifecycle(): void '__construct' => static function () use (&$constructed) { $constructed++; }, + 'mustRun' => '__itself', 'stop' => 0 ]) ); @@ -283,7 +285,9 @@ public function should_throw_if_docker_compose_start_fails(): void */ public function should_throw_if_running_file_cannot_be_written(): void { - $this->setClassMock(Process::class, $this->makeEmptyClass(Process::class, [])); + $this->setClassMock(Process::class, $this->makeEmptyClass(Process::class, [ + 'mustRun' => '__itself', + ])); $config = ['suites' => ['end2end'], 'compose-file' => 'docker-compose.yml']; $options = []; @@ -341,6 +345,7 @@ public function should_throw_if_running_file_cannot_be_removed_while_stopping(): $this->setClassMock( Process::class, $this->makeEmptyClass(Process::class, [ + 'mustRun' => '__itself', 'stop' => 0 ]) ); @@ -373,6 +378,7 @@ public function should_produce_information_correctly(): void $this->setClassMock( Process::class, $this->makeEmptyClass(Process::class, [ + 'mustRun' => '__itself', 'getOutput' => static function () { return Yaml::dump(['services' => ['foo' => ['ports' => ['8088:80']]]]); }, diff --git a/tests/unit/lucatume/WPBrowser/Module/WPFilesystemTest.php b/tests/unit/lucatume/WPBrowser/Module/WPFilesystemTest.php index 6edf81a4d..ade66ccb7 100644 --- a/tests/unit/lucatume/WPBrowser/Module/WPFilesystemTest.php +++ b/tests/unit/lucatume/WPBrowser/Module/WPFilesystemTest.php @@ -999,7 +999,7 @@ public function it_should_allow_having_a_plugin_with_code(): void $this->assertEquals(Strings::normalizeNewLine($expected), Strings::normalizeNewLine(file_get_contents($pluginFile))); - $sut->_after(new class extends Unit { + $sut->_after(new class('test-test-test') extends Unit { }); $this->assertFileNotExists($pluginFile); @@ -1042,7 +1042,7 @@ public function it_should_allow_having_a_single_file_plugin_with_code(): void $this->assertEquals(Strings::normalizeNewLine($expected), Strings::normalizeNewLine(file_get_contents($pluginFile))); - $sut->_after(new class extends Unit { + $sut->_after(new class('test-test-test') extends Unit { }); $this->assertFileNotExists($pluginFile); @@ -1087,7 +1087,7 @@ public function it_should_allow_having_a_mu_plugin_with_code(): void $this->assertEquals(Strings::normalizeNewLine($expected), Strings::normalizeNewLine(file_get_contents($muPluginFile))); - $sut->_after(new class extends Unit { + $sut->_after(new class('test-test-test') extends Unit { }); $this->assertFileNotExists($muPluginFile); @@ -1139,7 +1139,7 @@ public function it_should_allow_having_a_theme_with_code(): void $this->assertEquals(Strings::normalizeNewLine($expectedIndex), Strings::normalizeNewLine(file_get_contents($themeIndexFile))); - $sut->_after(new class extends Unit { + $sut->_after(new class('test-test-test') extends Unit { }); $this->assertFileNotExists($themeStyleFile); @@ -1195,7 +1195,7 @@ public function it_should_allow_having_a_theme_with_code_and_functions_file(): v $this->assertEquals(Strings::normalizeNewLine($expectedIndex), Strings::normalizeNewLine(file_get_contents($themeFunctionsFile))); - $sut->_after(new class extends Unit { + $sut->_after(new class('test-test-test') extends Unit { }); $this->assertFileNotExists($themeStyleFile); @@ -1242,7 +1242,7 @@ public function should_allow_opening_php_tag_when_having_plugin(): void $this->assertEquals(Strings::normalizeNewLine($expected), Strings::normalizeNewLine(file_get_contents($pluginFile))); - $sut->_after(new class extends Unit { + $sut->_after(new class('test-test-test') extends Unit { }); $this->assertFileNotExists($pluginFile); @@ -1289,7 +1289,7 @@ public function should_allow_the_opening_php_tag_when_having_a_mu_plugin(): void $this->assertEquals(Strings::normalizeNewLine($expected), Strings::normalizeNewLine(file_get_contents($muPluginFile))); - $sut->_after(new class extends Unit { + $sut->_after(new class('test-test-test') extends Unit { }); $this->assertFileNotExists($muPluginFile); @@ -1346,7 +1346,7 @@ public function should_allow_the_opening_php_tag_when_having_a_theme(): void $this->assertEquals(Strings::normalizeNewLine($expectedIndex), Strings::normalizeNewLine(file_get_contents($themeFunctionsFile))); - $sut->_after(new class extends Unit { + $sut->_after(new class('test-test-test') extends Unit { }); $this->assertFileNotExists($themeStyleFile); @@ -1401,7 +1401,7 @@ public function should_allow_using_different_directory_separators_to_have_plugin $this->assertEquals(Strings::normalizeNewLine($expected), Strings::normalizeNewLine(file_get_contents($pluginFile))); - $sut->_after(new class extends Unit { + $sut->_after(new class('test-test-test') extends Unit { }); $this->assertFileNotExists($pluginFile); diff --git a/tests/unit/lucatume/WPBrowser/Module/WPLoaderTest.php b/tests/unit/lucatume/WPBrowser/Module/WPLoaderTest.php index 88bdb64f0..52e42cc1f 100644 --- a/tests/unit/lucatume/WPBrowser/Module/WPLoaderTest.php +++ b/tests/unit/lucatume/WPBrowser/Module/WPLoaderTest.php @@ -30,7 +30,10 @@ use lucatume\WPBrowser\WordPress\InstallationState\Scaffolded; use PHPUnit\Framework\Assert; use PHPUnit\Framework\TestResult; +use PHPUnit\Runner\Version as PHPUnitVersion; +use PHPUnit\TextUI\Configuration\Registry as ConfigurationRegistry; use stdClass; +use Symfony\Component\VarDumper\VarDumper; use tad\Codeception\SnapshotAssertions\SnapshotAssertions; use UnitTester; use WP_Theme; @@ -127,15 +130,6 @@ public function unsetEnvVars(): void } } - /** - * @return WPLoader - */ - private function module(array $moduleContainerConfig = [], ?array $moduleConfig = null): WPLoader - { - $this->mockModuleContainer = new ModuleContainer(new Di(), $moduleContainerConfig); - return new WPLoader($this->mockModuleContainer, ($moduleConfig ?? $this->config)); - } - /** * It should throw if cannot connect to the database * @@ -157,6 +151,15 @@ public function should_throw_if_cannot_connect_to_the_database(): void $this->module()->_initialize(); } + /** + * @return WPLoader + */ + private function module(array $moduleContainerConfig = [], ?array $moduleConfig = null): WPLoader + { + $this->mockModuleContainer = new ModuleContainer(new Di(), $moduleContainerConfig); + return new WPLoader($this->mockModuleContainer, ($moduleConfig ?? $this->config)); + } + /** * It should throw if wpRootFolder is not valid * @@ -1551,7 +1554,7 @@ public function should_rethrow_on_failure_to_load_a_dump_file(): void $this->expectException(ModuleException::class); $this->assertInIsolation(static function () use ($wpLoader, $dumpFiles) { - uopz_set_return('fopen', function (string $file, ...$args)use($dumpFiles) { + uopz_set_return('fopen', function (string $file, ...$args) use ($dumpFiles) { return in_array($file, $dumpFiles, true) ? false : fopen($file, ...$args); }, true); $wpLoader->_initialize(); @@ -1923,8 +1926,18 @@ public function should_not_backup_globals_by_default(): void 'dbUrl' => $db->getDbUrl(), ]; $wpLoader = $this->module(); + $serializedPhpunitConfiguration = (int)PHPUnitVersion::series() >= 10 ? + serialize(ConfigurationRegistry::get()) + : null; + + $this->assertInIsolation(static function () use ($wpLoader, $testcaseFile, $serializedPhpunitConfiguration) { + if ((int)PHPUnitVersion::series() >= 10) { + $reflector = new \ReflectionClass(ConfigurationRegistry::class); + $instanceProp = $reflector->getProperty('instance'); + $instanceProp->setAccessible(true); + $instanceProp->setValue(unserialize($serializedPhpunitConfiguration)); + } - $this->assertInIsolation(static function () use ($wpLoader, $testcaseFile) { $wpLoader->_initialize(); Assert::assertTrue(function_exists('do_action')); @@ -1932,10 +1945,14 @@ public function should_not_backup_globals_by_default(): void require_once $testcaseFile; $testCase = new \BackupControlTestCase('testBackupGlobalsIsFalse'); - /** @var TestResult $result */ - $result = $testCase->run(); - - Assert::assertTrue($result->wasSuccessful()); + if ((int)PHPUnitVersion::series() >= 10) { + $testCase->run(); + $status = $testCase->status(); + Assert::assertTrue($status->isSuccess()); + } else { + $result = $testCase->run(); + Assert::assertTrue($result->wasSuccessful()); + } }); } @@ -1965,6 +1982,9 @@ public function should_allow_controlling_the_backup_of_global_variables_in_the_w ); $testcaseFile = codecept_data_dir('files/BackupControlTestCase.php'); $overridingTestCaseFile = codecept_data_dir('files/BackupControlTestCaseOverridingTestCase.php'); + $serializedPhpunitConfiguration = (int)PHPUnitVersion::series() >= 10 ? + serialize(ConfigurationRegistry::get()) + : null; // Set`WPLoader.backupGlobals` to `false`. $this->config = [ @@ -1974,7 +1994,14 @@ public function should_allow_controlling_the_backup_of_global_variables_in_the_w ]; $wpLoader = $this->module(); - $this->assertInIsolation(static function () use ($wpLoader, $testcaseFile) { + $this->assertInIsolation(static function () use ($wpLoader, $testcaseFile, $serializedPhpunitConfiguration) { + if ((int)PHPUnitVersion::series() >= 10) { + $reflector = new \ReflectionClass(ConfigurationRegistry::class); + $instanceProp = $reflector->getProperty('instance'); + $instanceProp->setAccessible(true); + $instanceProp->setValue(unserialize($serializedPhpunitConfiguration)); + } + $wpLoader->_initialize(); Assert::assertTrue(function_exists('do_action')); @@ -1982,10 +2009,15 @@ public function should_allow_controlling_the_backup_of_global_variables_in_the_w require_once $testcaseFile; $testCase = new \BackupControlTestCase('testBackupGlobalsIsFalse'); - /** @var TestResult $result */ - $result = $testCase->run(); - - Assert::assertTrue($result->wasSuccessful()); + if ((int)PHPUnitVersion::series() >= 10) { + $testCase->run(); + $status = $testCase->status(); + Assert::assertTrue($status->isSuccess()); + } else { + /** @var TestResult $result */ + $result = $testCase->run(); + Assert::assertTrue($result->wasSuccessful()); + } }); // Set `WPLoader.backupGlobals` to `true`. @@ -1996,7 +2028,13 @@ public function should_allow_controlling_the_backup_of_global_variables_in_the_w ]; $wpLoader = $this->module(); - $this->assertInIsolation(static function () use ($wpLoader, $testcaseFile) { + $this->assertInIsolation(static function () use ($wpLoader, $testcaseFile, $serializedPhpunitConfiguration) { + if ((int)PHPUnitVersion::series() >= 10) { + $reflector = new \ReflectionClass(ConfigurationRegistry::class); + $instanceProp = $reflector->getProperty('instance'); + $instanceProp->setAccessible(true); + $instanceProp->setValue(unserialize($serializedPhpunitConfiguration)); + } $wpLoader->_initialize(); Assert::assertTrue(function_exists('do_action')); @@ -2004,10 +2042,15 @@ public function should_allow_controlling_the_backup_of_global_variables_in_the_w require_once $testcaseFile; $testCase = new \BackupControlTestCase('testBackupGlobalsIsTrue'); - /** @var TestResult $result */ - $result = $testCase->run(); - - Assert::assertTrue($result->wasSuccessful()); + if ((int)PHPUnitVersion::series() >= 10) { + $testCase->run(); + $status = $testCase->status(); + Assert::assertTrue($status->isSuccess()); + } else { + /** @var TestResult $result */ + $result = $testCase->run(); + Assert::assertTrue($result->wasSuccessful()); + } }); // Do not set `WPLoader.backupGlobals`, but use the default value of `false`. @@ -2017,7 +2060,13 @@ public function should_allow_controlling_the_backup_of_global_variables_in_the_w ]; $wpLoader = $this->module(); - $this->assertInIsolation(static function () use ($wpLoader, $testcaseFile) { + $this->assertInIsolation(static function () use ($wpLoader, $testcaseFile, $serializedPhpunitConfiguration) { + if ((int)PHPUnitVersion::series() >= 10) { + $reflector = new \ReflectionClass(ConfigurationRegistry::class); + $instanceProp = $reflector->getProperty('instance'); + $instanceProp->setAccessible(true); + $instanceProp->setValue(unserialize($serializedPhpunitConfiguration)); + } $wpLoader->_initialize(); Assert::assertTrue(function_exists('do_action')); @@ -2025,10 +2074,16 @@ public function should_allow_controlling_the_backup_of_global_variables_in_the_w require_once $testcaseFile; $testCase = new \BackupControlTestCase('testBackupGlobalsIsFalse'); - /** @var TestResult $result */ - $result = $testCase->run(); - Assert::assertTrue($result->wasSuccessful()); + if ((int)PHPUnitVersion::series() >= 10) { + $testCase->run(); + $status = $testCase->status(); + Assert::assertTrue($status->isSuccess()); + } else { + /** @var TestResult $result */ + $result = $testCase->run(); + Assert::assertTrue($result->wasSuccessful()); + } }); // Set `WPLoader.backupGlobals` to `true`, but use a use-case that sets it explicitly to `false`. @@ -2039,7 +2094,13 @@ public function should_allow_controlling_the_backup_of_global_variables_in_the_w ]; $wpLoader = $this->module(); - $this->assertInIsolation(static function () use ($wpLoader, $overridingTestCaseFile) { + $this->assertInIsolation(static function () use ($wpLoader, $overridingTestCaseFile, $serializedPhpunitConfiguration) { + if ((int)PHPUnitVersion::series() >= 10) { + $reflector = new \ReflectionClass(ConfigurationRegistry::class); + $instanceProp = $reflector->getProperty('instance'); + $instanceProp->setAccessible(true); + $instanceProp->setValue(unserialize($serializedPhpunitConfiguration)); + } $wpLoader->_initialize(); Assert::assertTrue(function_exists('do_action')); @@ -2047,10 +2108,16 @@ public function should_allow_controlling_the_backup_of_global_variables_in_the_w require_once $overridingTestCaseFile; $testCase = new \BackupControlTestCaseOverridingTestCase('testBackupGlobalsIsFalse'); - /** @var TestResult $result */ - $result = $testCase->run(); - Assert::assertTrue($result->wasSuccessful()); + if ((int)PHPUnitVersion::series() >= 10) { + $testCase->run(); + $status = $testCase->status(); + Assert::assertTrue($status->isSuccess()); + } else { + /** @var TestResult $result */ + $result = $testCase->run(); + Assert::assertTrue($result->wasSuccessful()); + } }); $this->config = [ @@ -2060,7 +2127,13 @@ public function should_allow_controlling_the_backup_of_global_variables_in_the_w $wpLoader = $this->module(); // Test that globals defined before the test runs should not be backed up by default. - $this->assertInIsolation(static function () use ($wpLoader, $testcaseFile) { + $this->assertInIsolation(static function () use ($wpLoader, $testcaseFile, $serializedPhpunitConfiguration) { + if ((int)PHPUnitVersion::series() >= 10) { + $reflector = new \ReflectionClass(ConfigurationRegistry::class); + $instanceProp = $reflector->getProperty('instance'); + $instanceProp->setAccessible(true); + $instanceProp->setValue(unserialize($serializedPhpunitConfiguration)); + } $wpLoader->_initialize(); Assert::assertTrue(function_exists('do_action')); @@ -2072,10 +2145,16 @@ public function should_allow_controlling_the_backup_of_global_variables_in_the_w require_once $testcaseFile; $testCase = new \BackupControlTestCase('testWillUpdateTheValueOfGlobalVar'); - /** @var TestResult $result */ - $result = $testCase->run(); - Assert::assertTrue($result->wasSuccessful()); + if ((int)PHPUnitVersion::series() >= 10) { + $testCase->run(); + $status = $testCase->status(); + Assert::assertTrue($status->isSuccess()); + } else { + /** @var TestResult $result */ + $result = $testCase->run(); + Assert::assertTrue($result->wasSuccessful()); + } // Check that the value of the global variable has been updated. Assert::assertEquals('updated_value', $_wpbrowser_test_global_var); @@ -2089,7 +2168,13 @@ public function should_allow_controlling_the_backup_of_global_variables_in_the_w $wpLoader = $this->module(); // Test that adding a global to the list of `backupGlobalsExcludeList` will not back it up. - $this->assertInIsolation(static function () use ($wpLoader, $testcaseFile) { + $this->assertInIsolation(static function () use ($wpLoader, $testcaseFile, $serializedPhpunitConfiguration) { + if ((int)PHPUnitVersion::series() >= 10) { + $reflector = new \ReflectionClass(ConfigurationRegistry::class); + $instanceProp = $reflector->getProperty('instance'); + $instanceProp->setAccessible(true); + $instanceProp->setValue(unserialize($serializedPhpunitConfiguration)); + } $wpLoader->_initialize(); Assert::assertTrue(function_exists('do_action')); @@ -2101,10 +2186,16 @@ public function should_allow_controlling_the_backup_of_global_variables_in_the_w require_once $testcaseFile; $testCase = new \BackupControlTestCase('testWillUpdateTheValueOfGlobalVar'); - /** @var TestResult $result */ - $result = $testCase->run(); - Assert::assertTrue($result->wasSuccessful()); + if ((int)PHPUnitVersion::series() >= 10) { + $testCase->run(); + $status = $testCase->status(); + Assert::assertTrue($status->isSuccess()); + } else { + /** @var TestResult $result */ + $result = $testCase->run(); + Assert::assertTrue($result->wasSuccessful()); + } // Check that the value of the global variable has been updated. Assert::assertEquals('updated_value', $_wpbrowser_test_global_var); @@ -2136,7 +2227,11 @@ public function should_allow_controlling_the_backup_of_static_attributes_in_the_ 'Test' ); $testcaseFile = codecept_data_dir('files/BackupControlTestCase.php'); - $overridingTestCaseFile = codecept_data_dir('files/BackupControlTestCaseOverridingTestCase.php'); + if ((int)PHPUnitVersion::series() >= 10) { + $overridingTestCaseFile = codecept_data_dir('files/BackupControlTestCaseOverridingTestCasePHPUnit10.php'); + } else { + $overridingTestCaseFile = codecept_data_dir('files/BackupControlTestCaseOverridingTestCase.php'); + } // Set`WPLoader.backupStaticAttributes` to `false`. $this->config = [ @@ -2145,8 +2240,17 @@ public function should_allow_controlling_the_backup_of_static_attributes_in_the_ 'backupStaticAttributes' => false, ]; $wpLoader = $this->module(); - - $this->assertInIsolation(static function () use ($wpLoader, $testcaseFile) { + $serializedPhpunitConfiguration = (int)PHPUnitVersion::series() >= 10 ? + serialize(ConfigurationRegistry::get()) + : null; + + $this->assertInIsolation(static function () use ($wpLoader, $testcaseFile, $serializedPhpunitConfiguration) { + if ((int)PHPUnitVersion::series() >= 10) { + $reflector = new \ReflectionClass(ConfigurationRegistry::class); + $instanceProp = $reflector->getProperty('instance'); + $instanceProp->setAccessible(true); + $instanceProp->setValue(unserialize($serializedPhpunitConfiguration)); + } $wpLoader->_initialize(); Assert::assertTrue(function_exists('do_action')); @@ -2154,10 +2258,16 @@ public function should_allow_controlling_the_backup_of_static_attributes_in_the_ require_once $testcaseFile; $testCase = new \BackupControlTestCase('testWillAlterStoreStaticAttribute'); - /** @var TestResult $result */ - $result = $testCase->run(); - Assert::assertTrue($result->wasSuccessful()); + if ((int)PHPUnitVersion::series() >= 10) { + $testCase->run(); + $status = $testCase->status(); + Assert::assertTrue($status->isSuccess()); + } else { + /** @var TestResult $result */ + $result = $testCase->run(); + Assert::assertTrue($result->wasSuccessful()); + } Assert::assertEquals('updated_value', \BackupControlTestCaseStore::$staticAttribute); }); @@ -2169,7 +2279,13 @@ public function should_allow_controlling_the_backup_of_static_attributes_in_the_ ]; $wpLoader = $this->module(); - $this->assertInIsolation(static function () use ($wpLoader, $testcaseFile) { + $this->assertInIsolation(static function () use ($wpLoader, $testcaseFile, $serializedPhpunitConfiguration) { + if ((int)PHPUnitVersion::series() >= 10) { + $reflector = new \ReflectionClass(ConfigurationRegistry::class); + $instanceProp = $reflector->getProperty('instance'); + $instanceProp->setAccessible(true); + $instanceProp->setValue(unserialize($serializedPhpunitConfiguration)); + } $wpLoader->_initialize(); Assert::assertTrue(function_exists('do_action')); @@ -2177,10 +2293,16 @@ public function should_allow_controlling_the_backup_of_static_attributes_in_the_ require_once $testcaseFile; $testCase = new \BackupControlTestCase('testWillAlterStoreStaticAttribute'); - /** @var TestResult $result */ - $result = $testCase->run(); - Assert::assertTrue($result->wasSuccessful()); + if ((int)PHPUnitVersion::series() >= 10) { + $testCase->run(); + $status = $testCase->status(); + Assert::assertTrue($status->isSuccess()); + } else { + /** @var TestResult $result */ + $result = $testCase->run(); + Assert::assertTrue($result->wasSuccessful()); + } Assert::assertEquals('updated_value', \BackupControlTestCaseStore::$staticAttribute); }); @@ -2193,7 +2315,13 @@ public function should_allow_controlling_the_backup_of_static_attributes_in_the_ ]; $wpLoader = $this->module(); - $this->assertInIsolation(static function () use ($wpLoader, $overridingTestCaseFile) { + $this->assertInIsolation(static function () use ($wpLoader, $overridingTestCaseFile, $serializedPhpunitConfiguration) { + if ((int)PHPUnitVersion::series() >= 10) { + $reflector = new \ReflectionClass(ConfigurationRegistry::class); + $instanceProp = $reflector->getProperty('instance'); + $instanceProp->setAccessible(true); + $instanceProp->setValue(unserialize($serializedPhpunitConfiguration)); + } $wpLoader->_initialize(); Assert::assertTrue(function_exists('do_action')); @@ -2201,10 +2329,16 @@ public function should_allow_controlling_the_backup_of_static_attributes_in_the_ require_once $overridingTestCaseFile; $testCase = new \BackupControlTestCaseOverridingTestCase('testWillAlterStoreStaticAttribute'); - /** @var TestResult $result */ - $result = $testCase->run(); - Assert::assertTrue($result->wasSuccessful()); + if ((int)PHPUnitVersion::series() >= 10) { + $testCase->run(); + $status = $testCase->status(); + Assert::assertTrue($status->isSuccess()); + } else { + /** @var TestResult $result */ + $result = $testCase->run(); + Assert::assertTrue($result->wasSuccessful()); + } Assert::assertEquals('updated_value', \BackupControlTestCaseOverridingStore::$staticAttribute); }); @@ -2222,7 +2356,13 @@ public function should_allow_controlling_the_backup_of_static_attributes_in_the_ $wpLoader = $this->module(); $this->assertInIsolation( - static function () use ($wpLoader, $testcaseFile) { + static function () use ($wpLoader, $testcaseFile, $serializedPhpunitConfiguration) { + if ((int)PHPUnitVersion::series() >= 10) { + $reflector = new \ReflectionClass(ConfigurationRegistry::class); + $instanceProp = $reflector->getProperty('instance'); + $instanceProp->setAccessible(true); + $instanceProp->setValue(unserialize($serializedPhpunitConfiguration)); + } $wpLoader->_initialize(); Assert::assertTrue(function_exists('do_action')); @@ -2230,10 +2370,16 @@ static function () use ($wpLoader, $testcaseFile) { require_once $testcaseFile; $testCase = new \BackupControlTestCase('testWillAlterStoreStaticAttribute'); - /** @var TestResult $result */ - $result = $testCase->run(); - Assert::assertTrue($result->wasSuccessful()); + if ((int)PHPUnitVersion::series() >= 10) { + $testCase->run(); + $status = $testCase->status(); + Assert::assertTrue($status->isSuccess()); + } else { + /** @var TestResult $result */ + $result = $testCase->run(); + Assert::assertTrue($result->wasSuccessful()); + } Assert::assertEquals('updated_value', \BackupControlTestCaseStore::$staticAttribute); Assert::assertEquals('initial_value', \BackupControlTestCaseStore::$staticAttributeTwo); From 190e197ad425d848e3739ca53cc6fa6544fd4924 Mon Sep 17 00:00:00 2001 From: Luca Tumedei Date: Tue, 30 Jul 2024 09:23:11 +0200 Subject: [PATCH 09/30] fix(StubFactory) more cross PHPUnit fixes --- tests/_support/StubClassFactory.php | 67 ++++++++++------------------- 1 file changed, 23 insertions(+), 44 deletions(-) diff --git a/tests/_support/StubClassFactory.php b/tests/_support/StubClassFactory.php index c7d1b01de..c9a46492f 100644 --- a/tests/_support/StubClassFactory.php +++ b/tests/_support/StubClassFactory.php @@ -15,9 +15,7 @@ class StubClassFactory { public function __construct(%3$s) { - $this->__phpunit_originalObject = %4$s::getPhpunitOriginalObject(%1$s"); - $this->__phpunit_returnValueGeneration = %4$s::getPhpunitReturnValueGeneration("%1$s"); - $this->__phpunit_invocationMocker = %4$s::getPhpunitInvocationMocker("%1$s"); + %4$s::connectToStub($this, true); %4$s::assertConstructorConditions("%1$s", func_get_args()); %4$s::setMockForClassName("%1$s", $this); } @@ -26,8 +24,7 @@ public function __construct(%3$s) { public function __construct(%3$s) { - $this->__phpunit_returnValueGeneration = %4$s::getPhpunitReturnValueGeneration("%1$s"); - $this->__phpunit_invocationMocker = %4$s::getPhpunitInvocationMocker("%1$s"); + %4$s::connectToStub($this, false); %4$s::assertConstructorConditions("%1$s", func_get_args()); %4$s::setMockForClassName("%1$s", $this); } @@ -56,6 +53,10 @@ public function __construct(%3$s) * @var array */ private static array $mockByClassName = []; + /** + * @var}> + */ + private static array $stubParametersByClassName = []; public static function setMockForClassName(string $mockClassName, object $mock): void { @@ -69,46 +70,23 @@ public static function tearDown(): void self::$mockByClassName = []; } - /** - * @throws ReflectionException - */ - public static function getPhpunitOriginalObject(string $mockClassName): object - { - $value = Property::readPrivate(self::$stubByClassName[$mockClassName], '__phpunit_originalObject'); - - if (!is_object($value)) { - throw new ReflectionException('No original object found for ' . $mockClassName); - } - - return $value; - } - - /** - * @throws ReflectionException - */ - public static function getPhpunitReturnValueGeneration(string $mockClassName): object - { - $value = Property::readPrivate(self::$stubByClassName[$mockClassName], '__phpunit_returnValueGeneration'); - - if (!is_object($value)) { - throw new ReflectionException('No return value generation found for ' . $mockClassName); - } - - return $value; - } - - /** - * @throws ReflectionException - */ - public static function getPhpunitInvocationMocker(string $mockClassName): object - { - $value = Property::readPrivate(self::$stubByClassName[$mockClassName], '__phpunit_invocationMocker'); - - if (!is_object($value)) { - throw new ReflectionException('No invocation mocker found for ' . $mockClassName); + public static function connectToStub(object $mock, bool $includeOriginalObject): void{ + $mockClassName = get_class($mock); + [$class, $parameters] = self::$stubParametersByClassName[$mockClassName]; + $stub = Stub::makeEmpty($class, $parameters); + if($includeOriginalObject){ + Property::setPrivateProperties($mock, [ + '__phpunit_originalObject' => Property::readPrivate($stub, '__phpunit_originalObject'), + '__phpunit_returnValueGeneration' => Property::readPrivate($stub, '__phpunit_returnValueGeneration'), + '__phpunit_invocationMocker' => Property::readPrivate($stub, '__phpunit_invocationMocker'), + ]); + } else { + Property::setPrivateProperties($mock, [ + '__phpunit_returnValueGeneration' => Property::readPrivate($stub, '__phpunit_returnValueGeneration'), + '__phpunit_invocationMocker' => Property::readPrivate($stub, '__phpunit_invocationMocker'), + ]); } - - return $value; + unset($stub); } /** @@ -195,6 +173,7 @@ public static function makeEmptyClass(string $class, array $parameters): string eval($classCode); self::$stubByClassName[$mockClassName] = $codeceptionStub; + self::$stubParametersByClassName[$mockClassName] = [$class, $parameters]; return $mockClassName; } From 4c7addac59033d89509cd4da2df909fd0f796c54 Mon Sep 17 00:00:00 2001 From: Luca Tumedei Date: Tue, 30 Jul 2024 13:37:01 +0200 Subject: [PATCH 10/30] test(unit) remove keys from provided data --- .../unit/lucatume/WPBrowser/Utils/ArrTest.php | 108 +++++++++--------- 1 file changed, 54 insertions(+), 54 deletions(-) diff --git a/tests/unit/lucatume/WPBrowser/Utils/ArrTest.php b/tests/unit/lucatume/WPBrowser/Utils/ArrTest.php index 93b3865b0..bd0d28cf3 100644 --- a/tests/unit/lucatume/WPBrowser/Utils/ArrTest.php +++ b/tests/unit/lucatume/WPBrowser/Utils/ArrTest.php @@ -13,39 +13,39 @@ public function searchWithCallbackDataProvider(): array { return [ 'empty haystack, return true isNeedle' => [ - 'isNeedle' => static function (): bool { + static function (): bool { return true; }, - 'haystack' => [], - 'expected' => false + [], + false ], 'empty haystack, return false isNeedle' => [ - 'isNeedle' => static function (): bool { + static function (): bool { return false; }, - 'haystack' => [], - 'expected' => false + [], + false ], 'return false isNeedle' => [ - 'isNeedle' => static function (): bool { + static function (): bool { return false; }, - 'haystack' => [1, 2, 3], - 'expected' => false + [1, 2, 3], + false ], 'isNeedle is true for first item' => [ - 'isNeedle' => static function (int $item): bool { + static function (int $item): bool { return $item === 1; }, - 'haystack' => [1, 2, 3], - 'expected' => 0 + [1, 2, 3], + 0 ], 'isNeedle true for 3rd and 4th argument' => [ - 'isNeedle' => static function (int $item, int $key): bool { + static function (int $item, int $key): bool { return $item === 3 || $key === 3; }, - 'haystack' => [1, 2, 3, 4], - 'expected' => 2 + [1, 2, 3, 4], + 2 ], ]; } @@ -63,29 +63,29 @@ public function firstFromDataProvider(): array { return [ 'empty' => [ - 'value' => [], - 'default' => null, - 'expected' => null + [], + null, + null ], 'empty, default value is 23' => [ - 'value' => [], - 'default' => 23, - 'expected' => 23 + [], + 23, + 23 ], 'object value' => [ - 'value' => new stdClass(), - 'default' => null, - 'expected' => null + new stdClass(), + null, + null ], 'array of numbers' => [ - 'value' => [1, 2, 3], - 'default' => null, - 'expected' => 1 + [1, 2, 3], + null, + 1 ], 'array of numbers, default value is 23' => [ - 'value' => [1, 2, 3], - 'default' => 23, - 'expected' => 1 + [1, 2, 3], + 23, + 1 ] ]; } @@ -103,63 +103,63 @@ public function hasShapeDataProvider(): array { return [ 'empty array, empty shapes' => [ - 'array' => [], - 'expected' => true, + [], + true, [] ], 'empty array, 3 numbers shapes' => [ - 'array' => [], - 'expected' => false, + [], + false, ['int', 'int', 'int'] ], 'array has wrong shape' => [ - 'array' => [1, 2, 3], - 'expected' => false, + [1, 2, 3], + false, ['int', 'int', 'string'] ], 'array has 3 objects shape' => [ - 'array' => [new stdClass, new stdClass, new stdClass], - 'expected' => true, + [new stdClass, new stdClass, new stdClass], + true, ['stdClass', 'stdClass', 'stdClass'] ], 'array has 3 objects shape, misses 1' => [ - 'array' => [new stdClass, new stdClass], - 'expected' => false, + [new stdClass, new stdClass], + false, ['stdClass', 'stdClass', 'stdClass'] ], 'array has mixed shape' => [ - 'array' => [new stdClass, 2, '3'], - 'expected' => true, + [new stdClass, 2, '3'], + true, ['stdClass', 'int', 'string'] ], 'array has mixed shape, misses one' => [ - 'array' => [new stdClass, 2], - 'expected' => false, + [new stdClass, 2], + false, ['stdClass', 'int', 'string'] ], 'array has mixed shape, 3rd type is Closure' => [ - 'array' => [new stdClass, 2, '3'], - 'expected' => true, + [new stdClass, 2, '3'], + true, ['stdClass', 'int', fn(string $value): bool => $value === '3'] ], 'array has mixed shape, 3rd type is Closure, misses one' => [ - 'array' => [new stdClass, 2], - 'expected' => false, + [new stdClass, 2], + false, ['stdClass', 'int', fn(string $value): bool => $value === '3'] ], 'array has mixed shape with associative type' => [ - 'array' => ['a' => new stdClass, 'b' => 2, 'c' => '3'], - 'expected' => true, + ['a' => new stdClass, 'b' => 2, 'c' => '3'], + true, ['a' => 'stdClass', 'b' => 'int', 'c' => fn(string $value): bool => $value === '3'] ], 'array shape does not match mixed types' => [ - 'array' => ['a' => new stdClass, 'b' => 2, 'c' => '3'], - 'expected' => false, + ['a' => new stdClass, 'b' => 2, 'c' => '3'], + false, ['a' => 'stdClass', 'b' => 'int', 'c' => fn(string $value): bool => $value === '4'] ], 'array shape matches in different order' => [ - 'array' => ['a' => new stdClass, 'b' => 2, 'c' => '3'], - 'expected' => true, + ['a' => new stdClass, 'b' => 2, 'c' => '3'], + true, ['b' => 'int', 'c' => fn(string $value): bool => $value === '3', 'a' => 'stdClass'] ], ]; From f3dc67808e091ab8503c86276b40b09347be5d92 Mon Sep 17 00:00:00 2001 From: Luca Tumedei Date: Wed, 31 Jul 2024 08:41:18 +0200 Subject: [PATCH 11/30] refactor(LoopIsolation) better output refactor(LoopIsolation) better output --- tests/_support/Traits/LoopIsolation.php | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/_support/Traits/LoopIsolation.php b/tests/_support/Traits/LoopIsolation.php index e05fed649..1bbcabad4 100644 --- a/tests/_support/Traits/LoopIsolation.php +++ b/tests/_support/Traits/LoopIsolation.php @@ -48,9 +48,13 @@ protected function assertInIsolation( } if ($result->getExitCode() !== 0) { - codecept_debug('STDOUT: ' . $result->getStdoutBuffer()); - codecept_debug('STDERR: ' . $result->getStderrBuffer()); - $this->fail('Loop execution failed with exit code ' . $result->getExitCode()); + $failureMessage = sprintf( + "\nEXIT CODE: %s\n\nSTDOUT---\n%s\n\nSTDERR---\n%s\n", + $result->getExitCode(), + $result->getStdoutBuffer(), + $result->getStderrBuffer() + ); + $this->fail($failureMessage); } return $returnValue; From b1553ba21badd01ccefbdca29d01eb488bfcdbcf Mon Sep 17 00:00:00 2001 From: Luca Tumedei Date: Wed, 31 Jul 2024 15:05:47 +0200 Subject: [PATCH 12/30] refactor(WPLoader) make _loadWordPress public and internal for testing purposes --- src/Module/WPLoader.php | 9 +++++---- tests/unit/lucatume/WPBrowser/Module/WPLoaderTest.php | 10 +++++----- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/Module/WPLoader.php b/src/Module/WPLoader.php index 2e56f578d..dbc36bc31 100644 --- a/src/Module/WPLoader.php +++ b/src/Module/WPLoader.php @@ -490,7 +490,7 @@ public function _initialize(): void $this->debug('The WordPress installation will be loaded after all other modules have been initialized.'); Dispatcher::addListener(Events::SUITE_BEFORE, function (): void { - $this->loadWordPress(true); + $this->_loadWordPress(true); }); return; @@ -503,7 +503,7 @@ public function _initialize(): void WPTestCase::beStrictAboutWpdbConnectionId($config['beStrictAboutWpdbConnectionId']); - $this->loadWordPress(); + $this->_loadWordPress(); } /** @@ -553,10 +553,11 @@ private function ensureDbModuleCompat(): void /** * Loads WordPress calling the bootstrap file. * - * * @throws Throwable + * + * @internal This method is not covered by the backward compatibility promise for wp-browser. */ - private function loadWordPress(bool $loadOnly = false): void + public function _loadWordPress(bool $loadOnly = false): void { $this->loadConfigFiles(); diff --git a/tests/unit/lucatume/WPBrowser/Module/WPLoaderTest.php b/tests/unit/lucatume/WPBrowser/Module/WPLoaderTest.php index 52e42cc1f..fc91d6922 100644 --- a/tests/unit/lucatume/WPBrowser/Module/WPLoaderTest.php +++ b/tests/unit/lucatume/WPBrowser/Module/WPLoaderTest.php @@ -2,6 +2,7 @@ namespace lucatume\WPBrowser\Module; +use Codeception\Event\SuiteEvent; use Codeception\Events; use Codeception\Exception\ModuleConfigException; use Codeception\Exception\ModuleException; @@ -656,8 +657,7 @@ public function should_throw_if_load_only_and_word_press_not_installed(): void $this->assertInIsolation(static function () use ($wpRootDir, $wpLoader) { $wpLoader->_initialize(); - - Dispatcher::dispatch(Events::SUITE_BEFORE); + $wpLoader->_loadWordPress(); }); } @@ -710,7 +710,7 @@ public function should_load_word_press_before_suite_if_load_only_w_config_files( $actions[] = WPLoader::EVENT_AFTER_LOADONLY; }); - Dispatcher::dispatch(Events::SUITE_BEFORE); + $wpLoader->_loadWordPress(); Assert::assertEquals('test_file_002.php', getenv('LOADED_2')); Assert::assertEquals($wpRootDir . '/', ABSPATH); @@ -800,7 +800,7 @@ public function should_not_throw_when_load_only_true_and_using_db_module( $this->assertInIsolation(static function () use ($wpLoader, $wpRootDir) { $wpLoader->_initialize(); - Dispatcher::dispatch(Events::SUITE_BEFORE); + $wpLoader->_loadWordPress(); Assert::assertEquals($wpRootDir . '/', ABSPATH); }); @@ -1754,7 +1754,7 @@ public function should_initialize_correctly_with_sqlite_database_in_load_only_mo $this->assertInIsolation(static function () use ($wpLoader) { $wpLoader->_initialize(); - Dispatcher::dispatch(Events::SUITE_BEFORE); + $wpLoader->_loadWordPress(); Assert::assertTrue(function_exists('do_action')); Assert::assertInstanceOf(\WP_User::class, wp_get_current_user()); From 459591289af43b1b43034635450142871f286ce4 Mon Sep 17 00:00:00 2001 From: Luca Tumedei Date: Wed, 31 Jul 2024 19:23:33 +0200 Subject: [PATCH 13/30] test(unit) correct expectation --- tests/unit/lucatume/WPBrowser/Module/WPFilesystemTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/lucatume/WPBrowser/Module/WPFilesystemTest.php b/tests/unit/lucatume/WPBrowser/Module/WPFilesystemTest.php index ade66ccb7..d06840dc3 100644 --- a/tests/unit/lucatume/WPBrowser/Module/WPFilesystemTest.php +++ b/tests/unit/lucatume/WPBrowser/Module/WPFilesystemTest.php @@ -300,10 +300,10 @@ public function it_should_allow_to_see_a_file_in_the_uploads_folder_based_on_the $sut = $this->module(); $sut->seeUploadedFileFound('file.txt', time()); - $sut->dontSeeUploadedFileFound('file.txt', 'last month'); + $sut->dontSeeUploadedFileFound('file.txt', 'next month'); $this->expectException(AssertionFailedError::class); $sut->seeUploadedFileFound('some-other-file.txt', 'now'); - $sut->dontSeeUploadedFileFound('some-other-file.txt', 'last month'); + $sut->dontSeeUploadedFileFound('some-other-file.txt', 'next month'); } /** From f4199a128a0c0a966c71b915d79f0218d10d7705 Mon Sep 17 00:00:00 2001 From: Luca Tumedei Date: Thu, 1 Aug 2024 08:23:51 +0200 Subject: [PATCH 14/30] test(unit) remove unused parameter --- tests/unit/lucatume/WPBrowser/Module/WPLoaderTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/lucatume/WPBrowser/Module/WPLoaderTest.php b/tests/unit/lucatume/WPBrowser/Module/WPLoaderTest.php index fc91d6922..430c9eec6 100644 --- a/tests/unit/lucatume/WPBrowser/Module/WPLoaderTest.php +++ b/tests/unit/lucatume/WPBrowser/Module/WPLoaderTest.php @@ -655,7 +655,7 @@ public function should_throw_if_load_only_and_word_press_not_installed(): void $this->expectException(InstallationException::class); $this->expectExceptionMessage(InstallationException::becauseWordPressIsNotInstalled()->getMessage()); - $this->assertInIsolation(static function () use ($wpRootDir, $wpLoader) { + $this->assertInIsolation(static function () use ($wpLoader) { $wpLoader->_initialize(); $wpLoader->_loadWordPress(); }); From 483cf608f80c03dd8e9c0e9d5ac6fb61729709d3 Mon Sep 17 00:00:00 2001 From: Luca Tumedei Date: Thu, 1 Aug 2024 08:24:38 +0200 Subject: [PATCH 15/30] refactor(Module/WPLoader) use loadOnly parameter from configuration if null --- src/Module/WPLoader.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Module/WPLoader.php b/src/Module/WPLoader.php index dbc36bc31..d2de1f706 100644 --- a/src/Module/WPLoader.php +++ b/src/Module/WPLoader.php @@ -555,10 +555,14 @@ private function ensureDbModuleCompat(): void * * @throws Throwable * - * @internal This method is not covered by the backward compatibility promise for wp-browser. + * @internal This method is not part of the module API. */ - public function _loadWordPress(bool $loadOnly = false): void + public function _loadWordPress(?bool $loadOnly = null): void { + $config = $this->config; + /** @var array{loadOnly: bool} $config */ + $loadOnly = $loadOnly ?? $config['loadOnly']; + $this->loadConfigFiles(); if ($loadOnly) { From 6a4590084f37061a8d8fac7de7b9241f7c26a8c3 Mon Sep 17 00:00:00 2001 From: Luca Tumedei Date: Thu, 1 Aug 2024 09:22:00 +0200 Subject: [PATCH 16/30] test(unit) break down into groups, cache Chromedriver --- .github/workflows/test.yaml | 29 +++- .../_generated/AcceptanceTesterActions.php | 2 +- .../_generated/ClimoduleTesterActions.php | 160 +++++++++--------- .../_generated/FunctionalTesterActions.php | 6 +- .../WPLoaderArbitraryPluginLocationTest.php | 2 + .../WPLoaderArbitraryThemeLocationTest.php | 2 + .../WPBrowser/Module/WPLoaderTest.php | 1 + .../WPBrowser/Module/WPTestCaseStrictTest.php | 2 + 8 files changed, 118 insertions(+), 86 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 01cafa390..6419ead85 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -37,7 +37,9 @@ jobs: - climodule - functional - muloader - - unit + - unit --skip-group=slow + - unit --group=isolated-1 + - unit --group=isolated-2 - webdriver - wpcli_module - wploader_multisite @@ -70,10 +72,33 @@ jobs: restore-keys: | ${{ runner.os }}-composer- + - name: Get Chrome version + id: chrome-version + run: | + echo "version=$(/usr/bin/google-chrome --version)" >> $GITHUB_OUTPUT + + - name: Get the vendor/bin directory path + id: vendor-bin-dir + run: | + echo "dir=${{ github.workspace }}/vendor/bin" >> $GITHUB_OUTPUT + + - name: Ensure the vendor/bin directory exists + run: mkdir -p ${{ steps.vendor-bin-dir.outputs.dir }} + + - name: Restore vendor/bin from cache + uses: actions/cache@v3 + id: vendor-bin-dir-cache + with: + path: ${{ steps.vendor-bin-dir.outputs.dir }} + key: ${{ runner.os }}-chromedriver-${{ steps.chrome-version.outputs.version }} + restore-keys: | + ${{ runner.os }}-chromedriver- + - name: Install dependencies run: composer update - - name: Update ChromeDriver + - name: Update ChromeDriver if required + if: steps.vendor-bin-dir-cache.outputs.cache-hit != 'true' run: vendor/bin/codecept chromedriver:update --binary /usr/bin/google-chrome - name: Create var/wordpress directory diff --git a/tests/_support/_generated/AcceptanceTesterActions.php b/tests/_support/_generated/AcceptanceTesterActions.php index 5989f54df..d579db888 100644 --- a/tests/_support/_generated/AcceptanceTesterActions.php +++ b/tests/_support/_generated/AcceptanceTesterActions.php @@ -1,4 +1,4 @@ -amOnPage('/'); @@ -5367,7 +5367,7 @@ public function amOnPage(string $page): void { * * Note that if the locator matches a button of type `submit`, the form will be submitted. * - * ``` php + * ```php * click('Logout'); @@ -5398,7 +5398,7 @@ public function click($link, $context = NULL): void { * You can specify a specific HTML element (via CSS or XPath) as the second * parameter to only search within that element. * - * ``` php + * ```php * see('Logout'); // I can suppose user is logged in * $I->see('Sign Up', 'h1'); // I can suppose it's a signup page @@ -5435,7 +5435,7 @@ public function see(string $text, $selector = NULL): void { * You can specify a specific HTML element (via CSS or XPath) as the second * parameter to only search within that element. * - * ``` php + * ```php * see('Logout'); // I can suppose user is logged in * $I->see('Sign Up', 'h1'); // I can suppose it's a signup page @@ -5542,7 +5542,7 @@ public function cantSee(string $text, $selector = NULL): void { * Checks that the current page contains the given string in its * raw source code. * - * ``` php + * ```php * seeInSource('

Green eggs & ham

'); * ``` @@ -5558,7 +5558,7 @@ public function seeInSource(string $raw): void { * Checks that the current page contains the given string in its * raw source code. * - * ``` php + * ```php * seeInSource('

Green eggs & ham

'); * ``` @@ -5608,7 +5608,7 @@ public function cantSeeInSource(string $raw): void { * Checks that there's a link with the specified text. * Give a full URL as the second parameter to match links with that exact URL. * - * ``` php + * ```php * seeLink('Logout'); // matches Logout * $I->seeLink('Logout','/logout'); // matches Logout @@ -5625,7 +5625,7 @@ public function seeLink(string $text, ?string $url = NULL): void { * Checks that there's a link with the specified text. * Give a full URL as the second parameter to match links with that exact URL. * - * ``` php + * ```php * seeLink('Logout'); // matches Logout * $I->seeLink('Logout','/logout'); // matches Logout @@ -5643,7 +5643,7 @@ public function canSeeLink(string $text, ?string $url = NULL): void { * Checks that the page doesn't contain a link with the given string. * If the second parameter is given, only links with a matching "href" attribute will be checked. * - * ``` php + * ```php * dontSeeLink('Logout'); // I suppose user is not logged in * $I->dontSeeLink('Checkout now', '/store/cart.php'); @@ -5660,7 +5660,7 @@ public function dontSeeLink(string $text, string $url = ""): void { * Checks that the page doesn't contain a link with the given string. * If the second parameter is given, only links with a matching "href" attribute will be checked. * - * ``` php + * ```php * dontSeeLink('Logout'); // I suppose user is not logged in * $I->dontSeeLink('Checkout now', '/store/cart.php'); @@ -5677,7 +5677,7 @@ public function cantSeeLink(string $text, string $url = ""): void { * * Checks that current URI contains the given string. * - * ``` php + * ```php * seeInCurrentUrl('home'); @@ -5695,7 +5695,7 @@ public function seeInCurrentUrl(string $uri): void { * [!] Conditional Assertion: Test won't be stopped on fail * Checks that current URI contains the given string. * - * ``` php + * ```php * seeInCurrentUrl('home'); @@ -5714,7 +5714,7 @@ public function canSeeInCurrentUrl(string $uri): void { * * Checks that the current URI doesn't contain the given string. * - * ``` php + * ```php * dontSeeInCurrentUrl('/users/'); * ``` @@ -5729,7 +5729,7 @@ public function dontSeeInCurrentUrl(string $uri): void { * [!] Conditional Assertion: Test won't be stopped on fail * Checks that the current URI doesn't contain the given string. * - * ``` php + * ```php * dontSeeInCurrentUrl('/users/'); * ``` @@ -5746,7 +5746,7 @@ public function cantSeeInCurrentUrl(string $uri): void { * Checks that the current URL is equal to the given string. * Unlike `seeInCurrentUrl`, this only matches the full URL. * - * ``` php + * ```php * seeCurrentUrlEquals('/'); @@ -5763,7 +5763,7 @@ public function seeCurrentUrlEquals(string $uri): void { * Checks that the current URL is equal to the given string. * Unlike `seeInCurrentUrl`, this only matches the full URL. * - * ``` php + * ```php * seeCurrentUrlEquals('/'); @@ -5781,7 +5781,7 @@ public function canSeeCurrentUrlEquals(string $uri): void { * Checks that the current URL doesn't equal the given string. * Unlike `dontSeeInCurrentUrl`, this only matches the full URL. * - * ``` php + * ```php * dontSeeCurrentUrlEquals('/'); @@ -5798,7 +5798,7 @@ public function dontSeeCurrentUrlEquals(string $uri): void { * Checks that the current URL doesn't equal the given string. * Unlike `dontSeeInCurrentUrl`, this only matches the full URL. * - * ``` php + * ```php * dontSeeCurrentUrlEquals('/'); @@ -5815,7 +5815,7 @@ public function cantSeeCurrentUrlEquals(string $uri): void { * * Checks that the current URL matches the given regular expression. * - * ``` php + * ```php * seeCurrentUrlMatches('~^/users/(\d+)~'); @@ -5831,7 +5831,7 @@ public function seeCurrentUrlMatches(string $uri): void { * [!] Conditional Assertion: Test won't be stopped on fail * Checks that the current URL matches the given regular expression. * - * ``` php + * ```php * seeCurrentUrlMatches('~^/users/(\d+)~'); @@ -5848,7 +5848,7 @@ public function canSeeCurrentUrlMatches(string $uri): void { * * Checks that current url doesn't match the given regular expression. * - * ``` php + * ```php * dontSeeCurrentUrlMatches('~^/users/(\d+)~'); @@ -5864,7 +5864,7 @@ public function dontSeeCurrentUrlMatches(string $uri): void { * [!] Conditional Assertion: Test won't be stopped on fail * Checks that current url doesn't match the given regular expression. * - * ``` php + * ```php * dontSeeCurrentUrlMatches('~^/users/(\d+)~'); @@ -5882,7 +5882,7 @@ public function cantSeeCurrentUrlMatches(string $uri): void { * Executes the given regular expression against the current URI and returns the first capturing group. * If no parameters are provided, the full URI is returned. * - * ``` php + * ```php * grabFromCurrentUrl('~^/user/(\d+)/~'); * $uri = $I->grabFromCurrentUrl(); @@ -5899,7 +5899,7 @@ public function grabFromCurrentUrl(?string $uri = NULL): mixed { * * Checks that the specified checkbox is checked. * - * ``` php + * ```php * seeCheckboxIsChecked('#agree'); // I suppose user agreed to terms * $I->seeCheckboxIsChecked('#signup_form input[type=checkbox]'); // I suppose user agreed to terms, If there is only one checkbox in form. @@ -5916,7 +5916,7 @@ public function seeCheckboxIsChecked($checkbox): void { * [!] Conditional Assertion: Test won't be stopped on fail * Checks that the specified checkbox is checked. * - * ``` php + * ```php * seeCheckboxIsChecked('#agree'); // I suppose user agreed to terms * $I->seeCheckboxIsChecked('#signup_form input[type=checkbox]'); // I suppose user agreed to terms, If there is only one checkbox in form. @@ -5934,7 +5934,7 @@ public function canSeeCheckboxIsChecked($checkbox): void { * * Check that the specified checkbox is unchecked. * - * ``` php + * ```php * dontSeeCheckboxIsChecked('#agree'); // I suppose user didn't agree to terms * $I->seeCheckboxIsChecked('#signup_form input[type=checkbox]'); // I suppose user didn't check the first checkbox in form. @@ -5950,7 +5950,7 @@ public function dontSeeCheckboxIsChecked($checkbox): void { * [!] Conditional Assertion: Test won't be stopped on fail * Check that the specified checkbox is unchecked. * - * ``` php + * ```php * dontSeeCheckboxIsChecked('#agree'); // I suppose user didn't agree to terms * $I->seeCheckboxIsChecked('#signup_form input[type=checkbox]'); // I suppose user didn't check the first checkbox in form. @@ -5968,7 +5968,7 @@ public function cantSeeCheckboxIsChecked($checkbox): void { * Checks that the given input field or textarea *equals* (i.e. not just contains) the given value. * Fields are matched by label text, the "name" attribute, CSS, or XPath. * - * ``` php + * ```php * seeInField('Body','Type your comment here'); * $I->seeInField('form textarea[name=body]','Type your comment here'); @@ -5991,7 +5991,7 @@ public function seeInField($field, $value): void { * Checks that the given input field or textarea *equals* (i.e. not just contains) the given value. * Fields are matched by label text, the "name" attribute, CSS, or XPath. * - * ``` php + * ```php * seeInField('Body','Type your comment here'); * $I->seeInField('form textarea[name=body]','Type your comment here'); @@ -6015,7 +6015,7 @@ public function canSeeInField($field, $value): void { * Checks that an input field or textarea doesn't contain the given value. * For fuzzy locators, the field is matched by label text, CSS and XPath. * - * ``` php + * ```php * dontSeeInField('Body','Type your comment here'); * $I->dontSeeInField('form textarea[name=body]','Type your comment here'); @@ -6037,7 +6037,7 @@ public function dontSeeInField($field, $value): void { * Checks that an input field or textarea doesn't contain the given value. * For fuzzy locators, the field is matched by label text, CSS and XPath. * - * ``` php + * ```php * dontSeeInField('Body','Type your comment here'); * $I->dontSeeInField('form textarea[name=body]','Type your comment here'); @@ -6060,7 +6060,7 @@ public function cantSeeInField($field, $value): void { * Checks if the array of form parameters (name => value) are set on the form matched with the * passed selector. * - * ``` php + * ```php * seeInFormFields('form[name=myform]', [ * 'input1' => 'value', @@ -6071,7 +6071,7 @@ public function cantSeeInField($field, $value): void { * For multi-select elements, or to check values of multiple elements with the same name, an * array may be passed: * - * ``` php + * ```php * seeInFormFields('.form-class', [ * 'multiselect' => [ @@ -6087,7 +6087,7 @@ public function cantSeeInField($field, $value): void { * * Additionally, checkbox values can be checked with a boolean. * - * ``` php + * ```php * seeInFormFields('#form-id', [ * 'checkbox1' => true, // passes if checked @@ -6097,7 +6097,7 @@ public function cantSeeInField($field, $value): void { * * Pair this with submitForm for quick testing magic. * - * ``` php + * ```php * 'value', @@ -6121,7 +6121,7 @@ public function seeInFormFields($formSelector, array $params): void { * Checks if the array of form parameters (name => value) are set on the form matched with the * passed selector. * - * ``` php + * ```php * seeInFormFields('form[name=myform]', [ * 'input1' => 'value', @@ -6132,7 +6132,7 @@ public function seeInFormFields($formSelector, array $params): void { * For multi-select elements, or to check values of multiple elements with the same name, an * array may be passed: * - * ``` php + * ```php * seeInFormFields('.form-class', [ * 'multiselect' => [ @@ -6148,7 +6148,7 @@ public function seeInFormFields($formSelector, array $params): void { * * Additionally, checkbox values can be checked with a boolean. * - * ``` php + * ```php * seeInFormFields('#form-id', [ * 'checkbox1' => true, // passes if checked @@ -6158,7 +6158,7 @@ public function seeInFormFields($formSelector, array $params): void { * * Pair this with submitForm for quick testing magic. * - * ``` php + * ```php * 'value', @@ -6183,7 +6183,7 @@ public function canSeeInFormFields($formSelector, array $params): void { * Checks if the array of form parameters (name => value) are not set on the form matched with * the passed selector. * - * ``` php + * ```php * dontSeeInFormFields('form[name=myform]', [ * 'input1' => 'non-existent value', @@ -6194,7 +6194,7 @@ public function canSeeInFormFields($formSelector, array $params): void { * To check that an element hasn't been assigned any one of many values, an array can be passed * as the value: * - * ``` php + * ```php * dontSeeInFormFields('.form-class', [ * 'fieldName' => [ @@ -6206,7 +6206,7 @@ public function canSeeInFormFields($formSelector, array $params): void { * * Additionally, checkbox values can be checked with a boolean. * - * ``` php + * ```php * dontSeeInFormFields('#form-id', [ * 'checkbox1' => true, // fails if checked @@ -6225,7 +6225,7 @@ public function dontSeeInFormFields($formSelector, array $params): void { * Checks if the array of form parameters (name => value) are not set on the form matched with * the passed selector. * - * ``` php + * ```php * dontSeeInFormFields('form[name=myform]', [ * 'input1' => 'non-existent value', @@ -6236,7 +6236,7 @@ public function dontSeeInFormFields($formSelector, array $params): void { * To check that an element hasn't been assigned any one of many values, an array can be passed * as the value: * - * ``` php + * ```php * dontSeeInFormFields('.form-class', [ * 'fieldName' => [ @@ -6248,7 +6248,7 @@ public function dontSeeInFormFields($formSelector, array $params): void { * * Additionally, checkbox values can be checked with a boolean. * - * ``` php + * ```php * dontSeeInFormFields('#form-id', [ * 'checkbox1' => true, // fails if checked @@ -6292,7 +6292,7 @@ public function cantSeeInFormFields($formSelector, array $params): void { * * Examples: * - * ``` php + * ```php * submitForm('#login', [ * 'login' => 'davert', @@ -6329,7 +6329,7 @@ public function cantSeeInFormFields($formSelector, array $params): void { * * You could write the following to submit it: * - * ``` php + * ```php * submitForm( * '#userForm', @@ -6369,7 +6369,7 @@ public function cantSeeInFormFields($formSelector, array $params): void { * This function works well when paired with `seeInFormFields()` * for quickly testing CRUD interfaces and form validation logic. * - * ``` php + * ```php * 'value', @@ -6387,7 +6387,7 @@ public function cantSeeInFormFields($formSelector, array $params): void { * you can use either the string value or boolean `true`/`false` which will * be replaced by the checkbox's value in the DOM. * - * ``` php + * ```php * submitForm('#my-form', [ * 'field1' => 'value', @@ -6447,7 +6447,7 @@ public function submitForm($selector, array $params, ?string $button = NULL): vo * * Fills a text field or textarea with the given string. * - * ``` php + * ```php * fillField("//input[@type='text']", "Hello World!"); * $I->fillField(['name' => 'email'], 'jon@example.com'); @@ -6464,7 +6464,7 @@ public function fillField($field, $value): void { * * Selects an option in a select tag or in radio button group. * - * ``` php + * ```php * selectOption('form select[name=account]', 'Premium'); * $I->selectOption('form input[name=payment]', 'Monthly'); @@ -6473,17 +6473,17 @@ public function fillField($field, $value): void { * * Provide an array for the second argument to select multiple options: * - * ``` php + * ```php * selectOption('Which OS do you use?', array('Windows','Linux')); + * $I->selectOption('Which OS do you use?', ['Windows', 'Linux']); * ``` * * Or provide an associative array for the second argument to specifically define which selection method should be used: * - * ``` php + * ```php * selectOption('Which OS do you use?', array('text' => 'Windows')); // Only search by text 'Windows' - * $I->selectOption('Which OS do you use?', array('value' => 'windows')); // Only search by value 'windows' + * $I->selectOption('Which OS do you use?', ['text' => 'Windows']); // Only search by text 'Windows' + * $I->selectOption('Which OS do you use?', ['value' => 'windows']); // Only search by value 'windows' * ``` * @see \Codeception\Lib\InnerBrowser::selectOption() */ @@ -6497,7 +6497,7 @@ public function selectOption($select, $option): void { * * Ticks a checkbox. For radio buttons, use the `selectOption` method instead. * - * ``` php + * ```php * checkOption('#agree'); * ``` @@ -6513,7 +6513,7 @@ public function checkOption($option): void { * * Unticks a checkbox. * - * ``` php + * ```php * uncheckOption('#notify'); * ``` @@ -6529,7 +6529,7 @@ public function uncheckOption($option): void { * * Attaches a file relative to the Codeception `_data` directory to the given file upload field. * - * ``` php + * ```php * attachFile('input[@type="file"]', 'prices.xls'); @@ -6624,7 +6624,7 @@ public function makeHtmlSnapshot(?string $name = NULL): void { * If a fuzzy locator is used, the element is found using CSS, XPath, * and by matching the full page source by regular expression. * - * ``` php + * ```php * grabTextFrom('h1'); * $heading = $I->grabTextFrom('descendant-or-self::h1'); @@ -6640,10 +6640,10 @@ public function grabTextFrom($cssOrXPathOrRegex): mixed { /** * [!] Method is generated. Documentation taken from corresponding module. * - * Grabs the value of the given attribute value from the given element. - * Fails if element is not found. + * Returns the value of the given attribute value from the given HTML element. For some attributes, the string `true` is returned instead of their literal value (e.g. `disabled="disabled"` or `required="required"`). + * Fails if the element is not found. Returns `null` if the attribute is not present on the element. * - * ``` php + * ```php * grabAttributeFrom('#tooltip', 'title'); * ``` @@ -6689,7 +6689,7 @@ public function grabMultiple($cssOrXpath, ?string $attribute = NULL): array { * Finds the value for the given form field. * If a fuzzy locator is used, the field is found by field name, CSS, and XPath. * - * ``` php + * ```php * grabValueFrom('Name'); * $name = $I->grabValueFrom('input[name=username]'); @@ -6709,7 +6709,7 @@ public function grabValueFrom($field): mixed { * Sets a cookie with the given name and value. * You can set additional cookie params like `domain`, `path`, `expires`, `secure` in array passed as last argument. * - * ``` php + * ```php * setCookie('PHPSESSID', 'el4ukv0kqbvoirg7nkp4dncpk3'); * ``` @@ -6755,7 +6755,7 @@ public function grabPageSource(): string { * Checks that a cookie with the given name is set. * You can set additional cookie params like `domain`, `path` as array passed in last argument. * - * ``` php + * ```php * seeCookie('PHPSESSID'); * ``` @@ -6773,7 +6773,7 @@ public function seeCookie($cookie, $params = []) { * Checks that a cookie with the given name is set. * You can set additional cookie params like `domain`, `path` as array passed in last argument. * - * ``` php + * ```php * seeCookie('PHPSESSID'); * ``` @@ -6834,7 +6834,7 @@ public function resetCookie($cookie, $params = []) { * You can also specify expected attributes of this element. * Only works if `` tag is present. * - * ``` php + * ```php * seeElement('.error'); * $I->seeElement('//form/input[1]'); @@ -6857,7 +6857,7 @@ public function seeElement($selector, array $attributes = []): void { * You can also specify expected attributes of this element. * Only works if `` tag is present. * - * ``` php + * ```php * seeElement('.error'); * $I->seeElement('//form/input[1]'); @@ -6880,7 +6880,7 @@ public function canSeeElement($selector, array $attributes = []): void { * Checks that the given element is invisible or not present on the page. * You can also specify expected attributes of this element. * - * ``` php + * ```php * dontSeeElement('.error'); * $I->dontSeeElement('//form/input[1]'); @@ -6899,7 +6899,7 @@ public function dontSeeElement($selector, array $attributes = []): void { * Checks that the given element is invisible or not present on the page. * You can also specify expected attributes of this element. * - * ``` php + * ```php * dontSeeElement('.error'); * $I->dontSeeElement('//form/input[1]'); @@ -6918,7 +6918,7 @@ public function cantSeeElement($selector, array $attributes = []): void { * * Checks that there are a certain number of elements matched by the given locator on the page. * - * ``` php + * ```php * seeNumberOfElements('tr', 10); * $I->seeNumberOfElements('tr', [0,10]); // between 0 and 10 elements @@ -6936,7 +6936,7 @@ public function seeNumberOfElements($selector, $expected): void { * [!] Conditional Assertion: Test won't be stopped on fail * Checks that there are a certain number of elements matched by the given locator on the page. * - * ``` php + * ```php * seeNumberOfElements('tr', 10); * $I->seeNumberOfElements('tr', [0,10]); // between 0 and 10 elements @@ -6955,7 +6955,7 @@ public function canSeeNumberOfElements($selector, $expected): void { * * Checks that the given option is selected. * - * ``` php + * ```php * seeOptionIsSelected('#form input[name=payment]', 'Visa'); * ``` @@ -6972,7 +6972,7 @@ public function seeOptionIsSelected($selector, $optionText) { * [!] Conditional Assertion: Test won't be stopped on fail * Checks that the given option is selected. * - * ``` php + * ```php * seeOptionIsSelected('#form input[name=payment]', 'Visa'); * ``` @@ -6990,7 +6990,7 @@ public function canSeeOptionIsSelected($selector, $optionText) { * * Checks that the given option is not selected. * - * ``` php + * ```php * dontSeeOptionIsSelected('#form input[name=payment]', 'Visa'); * ``` @@ -7007,7 +7007,7 @@ public function dontSeeOptionIsSelected($selector, $optionText) { * [!] Conditional Assertion: Test won't be stopped on fail * Checks that the given option is not selected. * - * ``` php + * ```php * dontSeeOptionIsSelected('#form input[name=payment]', 'Visa'); * ``` @@ -7225,7 +7225,7 @@ public function canSeeResponseCodeIsServerError(): void { * * Checks that the page title contains the given string. * - * ``` php + * ```php * seeInTitle('Blog - Post #1'); * ``` @@ -7242,7 +7242,7 @@ public function seeInTitle($title) { * [!] Conditional Assertion: Test won't be stopped on fail * Checks that the page title contains the given string. * - * ``` php + * ```php * seeInTitle('Blog - Post #1'); * ``` diff --git a/tests/_support/_generated/FunctionalTesterActions.php b/tests/_support/_generated/FunctionalTesterActions.php index 79c391a51..e0899ed90 100644 --- a/tests/_support/_generated/FunctionalTesterActions.php +++ b/tests/_support/_generated/FunctionalTesterActions.php @@ -1,4 +1,4 @@ -seeFileFound('shop.log'); * ``` * - * @param string|null $path The path, relative to the site uploads folder. + * @param string|int|null $path The path, relative to the site uploads folder. * * * @throws Exception If the path is a date string and is not parsable by the `strtotime` function. * @see \lucatume\WPBrowser\Module\WPFilesystem::amInUploadsPath() */ - public function amInUploadsPath(?string $path = NULL): void { + public function amInUploadsPath(string|int|null $path = NULL): void { $this->getScenario()->runStep(new \Codeception\Step\Condition('amInUploadsPath', func_get_args())); } diff --git a/tests/unit/lucatume/WPBrowser/Module/WPLoaderArbitraryPluginLocationTest.php b/tests/unit/lucatume/WPBrowser/Module/WPLoaderArbitraryPluginLocationTest.php index 839a45a00..b3b030b86 100644 --- a/tests/unit/lucatume/WPBrowser/Module/WPLoaderArbitraryPluginLocationTest.php +++ b/tests/unit/lucatume/WPBrowser/Module/WPLoaderArbitraryPluginLocationTest.php @@ -18,6 +18,8 @@ use PHPUnit\Framework\Assert; use tad\Codeception\SnapshotAssertions\SnapshotAssertions; +// @group slow +// @group isolated-2 class WPLoaderArbitraryPluginLocationTest extends Unit { use SnapshotAssertions; diff --git a/tests/unit/lucatume/WPBrowser/Module/WPLoaderArbitraryThemeLocationTest.php b/tests/unit/lucatume/WPBrowser/Module/WPLoaderArbitraryThemeLocationTest.php index 0f8222122..c3f9e4f59 100644 --- a/tests/unit/lucatume/WPBrowser/Module/WPLoaderArbitraryThemeLocationTest.php +++ b/tests/unit/lucatume/WPBrowser/Module/WPLoaderArbitraryThemeLocationTest.php @@ -19,6 +19,8 @@ use PHPUnit\Framework\Assert; use tad\Codeception\SnapshotAssertions\SnapshotAssertions; +// @group slow +// @group isolated-2 class WPLoaderArbitraryThemeLocationTest extends Unit { use SnapshotAssertions; diff --git a/tests/unit/lucatume/WPBrowser/Module/WPLoaderTest.php b/tests/unit/lucatume/WPBrowser/Module/WPLoaderTest.php index 430c9eec6..f879f81d0 100644 --- a/tests/unit/lucatume/WPBrowser/Module/WPLoaderTest.php +++ b/tests/unit/lucatume/WPBrowser/Module/WPLoaderTest.php @@ -44,6 +44,7 @@ /** * @group slow + * @group isolated-1 */ class WPLoaderTest extends Unit { diff --git a/tests/unit/lucatume/WPBrowser/Module/WPTestCaseStrictTest.php b/tests/unit/lucatume/WPBrowser/Module/WPTestCaseStrictTest.php index da6d5a7a7..1cd57f22a 100644 --- a/tests/unit/lucatume/WPBrowser/Module/WPTestCaseStrictTest.php +++ b/tests/unit/lucatume/WPBrowser/Module/WPTestCaseStrictTest.php @@ -17,6 +17,8 @@ use lucatume\WPBrowser\WordPress\Installation; use PHPUnit\Framework\Assert; +// @group slow +// @group isolated-2 class WPTestCaseStrictTest extends Unit { use LoopIsolation; From bfc179b7de7fda237908161dba84b2df247784d7 Mon Sep 17 00:00:00 2001 From: Luca Tumedei Date: Thu, 1 Aug 2024 14:24:56 +0200 Subject: [PATCH 17/30] test(unit) mock Chromedriver zip file existence correctly --- .../lucatume/WPBrowser/Utils/ChromedriverInstallerTest.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/unit/lucatume/WPBrowser/Utils/ChromedriverInstallerTest.php b/tests/unit/lucatume/WPBrowser/Utils/ChromedriverInstallerTest.php index c8c4c2a78..bbf062cc9 100644 --- a/tests/unit/lucatume/WPBrowser/Utils/ChromedriverInstallerTest.php +++ b/tests/unit/lucatume/WPBrowser/Utils/ChromedriverInstallerTest.php @@ -272,6 +272,9 @@ public function should_throw_if_download_url_for_chrome_version_cannot_be_found_ public function should_throw_if_existing_zip_file_cannot_be_removed(): void { $this->setFunctionReturn('sys_get_temp_dir', codecept_output_dir()); + $this->setFunctionReturn('is_file', function (string $file): bool { + return preg_match('~chromedriver\\.zip$~', $file) ? true : is_file($file); + },true); $this->setFunctionReturn('unlink', function (string $file): bool { return preg_match('~chromedriver\\.zip$~', $file) ? false : unlink($file); }, true); From 539ed8fd54900db7dd4caa0158d02fcfe5e75848 Mon Sep 17 00:00:00 2001 From: Luca Tumedei Date: Fri, 9 Aug 2024 10:07:23 +0200 Subject: [PATCH 18/30] feat(MysqlServerController) add Add the MysqlServerController extension, and related support classes to download, initialize and start a local MySQL Community Server on the machine. Together with the work in there, improve the `UopzFunctions` trait to return Closure to unset the mocks. --- .github/workflows/test.yaml | 44 +- Makefile | 3 +- codeception.dist.yml | 17 +- docs/extensions.md | 1 + docs/extensions/MySqlServerController.md | 94 ++ docs/traits/UopzFunctions.md | 72 +- mkdocs.yml | 1 + src/Extension/ChromeDriverController.php | 3 + src/Extension/MysqlServerController.php | 234 +++++ src/ManagedProcess/ChromeDriver.php | 2 +- src/ManagedProcess/ManagedProcessTrait.php | 3 +- src/ManagedProcess/MysqlServer.php | 547 ++++++++++ src/ManagedProcess/PhpBuiltInServer.php | 2 +- src/Traits/UopzFunctions.php | 84 +- src/Utils/Filesystem.php | 54 + src/Utils/MachineInformation.php | 49 + ...4.2-linux-glibc2.17-aarch64-minimal.tar.xz | Bin 0 -> 816 bytes ....4.2-linux-glibc2.17-x86_64-minimal.tar.xz | Bin 0 -> 1037 bytes .../mysql-8.4.2-macos14-arm64.tar.gz | Bin 0 -> 904 bytes .../mysql-8.4.2-macos14-x86_64.tar.gz | Bin 0 -> 905 bytes .../mock-archives/mysql-8.4.2-winx64.zip | Bin 0 -> 2327 bytes tests/_data/uopz-test/functions.php | 9 + .../Extension/MysqlServerControllerTest.php | 633 ++++++++++++ .../ManagedProcess/MysqlServerTest.php | 946 ++++++++++++++++++ .../WPBrowser/Traits/UopzFunctionsTest.php | 319 ++++++ .../WPBrowser/Utils/FilesystemTest.php | 21 + .../Utils/MachineInformationTest.php | 97 ++ 27 files changed, 3178 insertions(+), 57 deletions(-) create mode 100644 docs/extensions/MySqlServerController.md create mode 100644 src/Extension/MysqlServerController.php create mode 100644 src/ManagedProcess/MysqlServer.php create mode 100644 src/Utils/MachineInformation.php create mode 100644 tests/_data/mysql-server/mock-archives/mysql-8.4.2-linux-glibc2.17-aarch64-minimal.tar.xz create mode 100644 tests/_data/mysql-server/mock-archives/mysql-8.4.2-linux-glibc2.17-x86_64-minimal.tar.xz create mode 100644 tests/_data/mysql-server/mock-archives/mysql-8.4.2-macos14-arm64.tar.gz create mode 100644 tests/_data/mysql-server/mock-archives/mysql-8.4.2-macos14-x86_64.tar.gz create mode 100644 tests/_data/mysql-server/mock-archives/mysql-8.4.2-winx64.zip create mode 100644 tests/unit/lucatume/WPBrowser/Extension/MysqlServerControllerTest.php create mode 100644 tests/unit/lucatume/WPBrowser/ManagedProcess/MysqlServerTest.php create mode 100644 tests/unit/lucatume/WPBrowser/Utils/MachineInformationTest.php diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 6419ead85..dbf7f5411 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -31,20 +31,23 @@ jobs: test: strategy: matrix: - php_version: [ '8.0', '8.1', '8.2', '8.3' ] - suite: - - acceptance - - climodule - - functional - - muloader - - unit --skip-group=slow - - unit --group=isolated-1 - - unit --group=isolated-2 - - webdriver - - wpcli_module - - wploader_multisite - - wploader_wpdb_interaction - - wploadersuite +# php_version: [ '8.0', '8.1', '8.2', '8.3' ] +# suite: +# - acceptance +# - climodule +# - functional +# - muloader +# - unit --skip-group=slow +# - unit --group=isolated-1 +# - unit --group=isolated-2 +# - webdriver +# - wpcli_module +# - wploader_multisite +# - wploader_wpdb_interaction +# - wploadersuite + php_version: [ '8.0'] + suite: + - muloader name: ${{ matrix.suite }} php@${{ matrix.php_version }} runs-on: ubuntu-22.04 steps: @@ -72,10 +75,15 @@ jobs: restore-keys: | ${{ runner.os }}-composer- - - name: Get Chrome version - id: chrome-version + - name: Setup Chrome + id: setup-chrome + uses: browser-actions/setup-chrome@v1 + with: + install-dependencies: true + + - name: Check Chrome version and path run: | - echo "version=$(/usr/bin/google-chrome --version)" >> $GITHUB_OUTPUT + ${{ steps.setup-chrome.outputs.chrome-path }} --version - name: Get the vendor/bin directory path id: vendor-bin-dir @@ -99,7 +107,7 @@ jobs: - name: Update ChromeDriver if required if: steps.vendor-bin-dir-cache.outputs.cache-hit != 'true' - run: vendor/bin/codecept chromedriver:update --binary /usr/bin/google-chrome + run: vendor/bin/codecept chromedriver:update --binary ${{ steps.setup-chrome.outputs.chrome-path }} - name: Create var/wordpress directory run: mkdir -p var/wordpress diff --git a/Makefile b/Makefile index fea1505a0..2adb07c9f 100644 --- a/Makefile +++ b/Makefile @@ -46,7 +46,8 @@ wordpress_install: clean_procs: pgrep -f 'php -S' | xargs kill - pgrep chromedriver | xargs kill + -pkill -9 -f chromedriver + -pkill -9 -f mysqld rm -f var/_output/*.pid var/_output/*.running set -o allexport && source tests/.env && set +o allexport && docker compose down .PHONY: clean_procs diff --git a/codeception.dist.yml b/codeception.dist.yml index b1602a48e..21f89cc61 100644 --- a/codeception.dist.yml +++ b/codeception.dist.yml @@ -18,12 +18,6 @@ coverage: - src/* wpFolder: '%WORDPRESS_ROOT_DIR%' extensions: - enabled: - - "lucatume\\WPBrowser\\Extension\\EventDispatcherBridge" - - "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%' @@ -35,6 +29,17 @@ extensions: "lucatume\\WPBrowser\\Extension\\DockerComposeController": compose-file: docker-compose.yml env-file: tests/.env + "lucatume\\WPBrowser\\Extension\\MysqlServerController": + port: '%WORDPRESS_DB_LOCALHOST_PORT%' + database: '%WORDPRESS_DB_NAME%' + user: '%WORDPRESS_DB_USER%' + password: '%WORDPRESS_DB_PASSWORD%' + enabled: + - "lucatume\\WPBrowser\\Extension\\EventDispatcherBridge" + - "lucatume\\WPBrowser\\Extension\\BuiltInServerController" + - "lucatume\\WPBrowser\\Extension\\ChromeDriverController" + - "lucatume\\WPBrowser\\Extension\\MysqlServerController" + - "lucatume\\WPBrowser\\Extension\\IsolationSupport" commands: - "lucatume\\WPBrowser\\Command\\RunOriginal" - "lucatume\\WPBrowser\\Command\\RunAll" diff --git a/docs/extensions.md b/docs/extensions.md index 7fd2b7e6a..c86cbfdac 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -7,3 +7,4 @@ file, `codeception.yml` or `codeception.dist.yml` by default, in the `extensions * [IsolationSupport](extensions/IsolationSupport.md) * [Symlinker](extensions/Symlinker.md) * [EventDispatcherBridge](extensions/EventDispatcherBridge.md) +* [MySqlServerController](extensions/MySqlServerController.md) diff --git a/docs/extensions/MySqlServerController.md b/docs/extensions/MySqlServerController.md new file mode 100644 index 000000000..7350e368f --- /dev/null +++ b/docs/extensions/MySqlServerController.md @@ -0,0 +1,94 @@ +This extension will start and stop the MySQL server before and after the tests run. +The extension will take care of downloading the MySQL Community Server archive from +the [MySQL Community Server](https://dev.mysql.com/downloads/mysql/) site, place it in the `_mysql-server` +directory under the codeception output directory and initialize the server in the same directory. + +The aim of this extension is to allow running integration tests against a real MySQL server, without having to +install and configure a MySQL server on the machine. + +!!! warning + + Currently the MySQL Community Server version installed by this extension (8.4.2 LTS) **is not available for Windows on ARM.** + + If you are running Windows on ARM, you can either: + - Use a custom binary, see the [configuration examples](#configuration-examples) below + - Use the [Docker controller extension](DockerComposeController.md) to run the database from a Docker container. + +### Configuration + +The extension can be configured with the following parameters: + +* required + * `port` - the localhost port to use for the MySQL server, defaults to `8906`. + * `database` - the database that will be created when starting the server, defaults to `wordpress`. + * `user` - the user that will be created when starting the server, defaults to `wordpress`. The user will be granted + all privileges on the database specified by the `database` parameter. If the user is `root`, no furhter user will + be created. + * `password` - the password to use for the user specified by the `user` parameter, defaults to `wordpress`. If the + user is `root`, the root user will be set to the password specified by this parameter. +* optional + * `suites` - an array of Codeception suites to run the server for; if not set the server will be started for all the + suites. + * `binary` - the path to the MySQL server binary to use, defaults to `mysqld`, deafults to `null` to download and + initialize the correct version of MySQL server for the current platform and architecture. + * `shareDir` - the path to the directory to use for the MySQL server share, defaults to `null`. **This is required + when providing a custom binary**. + +### Configuration Examples + +Example configuration starting the server for all suites: + +```yaml +extensions: + enabled: + - "lucatume\WPBrowser\Extension\MySqlServerController": + port: 8906 + database: wordpress + user: wordpress + password: wordpress +``` + +The extension can access environment variables defined in the tests configuration file: + +```yaml +extensions: + enabled: + - "lucatume\WPBrowser\Extension\MySqlServerController": + port: '%MYSQL_SERVER_PORT%' + database: '%MYSQL_SERVER_DATABASE%' + user: '%MYSQL_SERVER_USER%' + password: '%MYSQL_SERVER_PASSWORD%' +``` + +Example configuration using the `root` user: + +```yaml +extensions: + enabled: + - "lucatume\WPBrowser\Extension\MySqlServerController": + port: 33446 + database: wordpress + user: root + password: secret +``` + +### Using a custom MySQL server binary + +The extension can be configured to use a custom MySQL server binary by setting the `binary` configuration parameter to +the absolute path to the binary: + +```yaml +extensions: + enabled: + - "lucatume\WPBrowser\Extension\MySqlServerController": + port: 33446 + database: wordpress + user: root + password: secret + binary: /usr/local/mysql/bin/mysqld +``` + +### This is a service extension + +This is a service extension that will be started and stopped by [the `dev:start`](../commands.md#devstart) +and [`dev:stop`](../commands.md#devstop) commands. diff --git a/docs/traits/UopzFunctions.md b/docs/traits/UopzFunctions.md index 93eec7aed..801cefeb8 100644 --- a/docs/traits/UopzFunctions.md +++ b/docs/traits/UopzFunctions.md @@ -155,15 +155,45 @@ class MyTest extends TestCase } ``` +If you need to reset a function return or mock during a test, you can use the unset Closure returned by each method setting up a mock or return value: + +```php +setFunctionReturn('someFunction', 'mocked-value'); + + $this->assertEquals('mocked-value', someFunction()); + + $unsetSomeFunctionReturn(); + + $this->assertEquals('original-value', someFunction()); + } +} +``` + ### Methods The `UopzFunctions` trait provides the following methods: #### setFunctionReturn -`setFunctionReturn(string $function, mixed $value, bool $execute = false): void` +`setFunctionReturn(string $function, mixed $value, bool $execute = false): Closure` Set the return value for the function `$function` to `$value`. +The Closure returned by this method can be used to unset the return value. If `$value` is a closure and `$execute` is `true`, then the return value will be the return value of the closure. @@ -224,9 +254,10 @@ functions, methods and class attributes after each test. #### setMethodReturn -`setMethodReturn(string $class, string $method, mixed $value, bool $execute = false): void` +`setMethodReturn(string $class, string $method, mixed $value, bool $execute = false): Closure` Sets the return value for the static or instance method `$method` of the class `$class` to `$value`. +The Closure returned by this method can be used to unset the return value. If `$value` is a closure and `$execute` is `true`, then the return value will be the return value of the closure. @@ -314,7 +345,7 @@ class MyTest extends WPTestCase } ``` -#### unsetmethodreturn +#### unsetMethodReturn `unsetmethodreturn(string $class, string $method): void` @@ -327,9 +358,10 @@ functions, methods and class attributes after each test. #### setFunctionHook -`setFunctionHook(string $function, Closure $hook): void` +`setFunctionHook(string $function, Closure $hook): Closure` Execute `$hook` when entering the function `$function`. +The Closure returned by this method can be used to unset the hook. Hooks can be set on both internal and user-defined functions. @@ -379,9 +411,10 @@ functions, methods and class attributes after each test. #### setMethodHook -`setMethodHook(string $class, string $method, Closure $hook): void` +`setMethodHook(string $class, string $method, Closure $hook): Closure` -Execute `$hook` when entering the static or instance method `$method` of the class `$class`. +Execute `$hook` when entering the static or instance method `$method` of the class `$class`. +The Closure returned by this method can be used to unset the hook. The keywords `self` and `$this` will be correctly bound to the class and the class instance respectively. @@ -461,9 +494,10 @@ functions, methods and class attributes after each test. #### setConstant -`setConstant(string $constant, mixed $value): void` +`setConstant(string $constant, mixed $value): Closure` Set the constant `$constant` to the value `$value`. +The Closure returned by this method can be used to unset the constant or reset it to its original value. If the constant is not already defined, it will be defined and set to the value `$value`. @@ -522,9 +556,10 @@ test. #### setClassConstant -`setClassConstant(string $class, string $constant, mixed $value): void` +`setClassConstant(string $class, string $constant, mixed $value): Closure` Set the constant `$constant` of the class `$class` to the value `$value`. +The Closure returned by this method can be used to unset the constant or reset it to its original value. If the class constant is not already defined, it will be defined and set to the value `$value`. @@ -565,9 +600,10 @@ functions, methods and class attributes after each test. #### setClassMock -`setClassMock(string $class, string|object $mock): void` +`setClassMock(string $class, string|object $mock): Closure` Use `$mock` instead of `$class` when creating new instances of the class `$class`. +The Closure returned by this method can be used to unset the mock. This method allows you to override magic methods as well as you would do with a normal class extension. @@ -727,9 +763,10 @@ attributes after each test. #### unsetClassFinalAttribute -`unsetClassFinalAttribute(string $class): void` +`unsetClassFinalAttribute(string $class): Closure` Remove the `final` attribute from the class `$class`. +The Closure returned by this method can be used to reset the `final` attribute. ```php config; diff --git a/src/Extension/MysqlServerController.php b/src/Extension/MysqlServerController.php new file mode 100644 index 000000000..238c19976 --- /dev/null +++ b/src/Extension/MysqlServerController.php @@ -0,0 +1,234 @@ +getPidFile(); + + if (is_file($pidFile)) { + $output->writeln('MySQL server already running.'); + + return; + } + + $port = $this->getPort(); + $database = $this->getDatabase(); + $user = $this->getUser(); + $password = $this->getPassword(); + $binary = $this->getBinary(); + $shareDir = $this->getShareDir($binary); + + $output->write("Starting MySQL server on port $port ..."); + try { + $this->mysqlServer = new MysqlServer( + codecept_output_dir('_mysql_server'), + $port, + $database, + $user, + $password, + $binary, + $shareDir + ); + $this->mysqlServer->setOutput($output); + $this->mysqlServer->start(); + } catch (\Exception $e) { + throw new ExtensionException($this, "Error while starting MySQL server. {$e->getMessage()}", $e); + } + $output->write(' ok', true); + } + + public function getPidFile(): string + { + return codecept_output_dir(self::PID_FILE_NAME); + } + + private function getDatabase(): string + { + /** @var array{database?: string} $config */ + $config = $this->config; + + if (isset($config['database']) && !(is_string($config['database']) && !empty($config['database']))) { + throw new ExtensionException( + $this, + 'The "database" configuration option must be a string.' + ); + } + + return $config['database'] ?? 'wordpress'; + } + + private function getUser(): string + { + /** @var array{user?: string} $config */ + $config = $this->config; + + if (isset($config['user']) && !(is_string($config['user']) && !empty($config['user']))) { + throw new ExtensionException( + $this, + 'The "user" configuration option must be a string.' + ); + } + + return $config['user'] ?? 'wordpress'; + } + + private function getPassword(): string + { + /** @var array{password?: string} $config */ + $config = $this->config; + + if (isset($config['password']) && !is_string($config['password'])) { + throw new ExtensionException( + $this, + 'The "password" configuration option must be a string.' + ); + } + + return $config['password'] ?? 'wordpress'; + } + + /** + * @throws ExtensionException + */ + public function getPort(): int + { + $config = $this->config; + if (isset($config['port']) + && !( + is_numeric($config['port']) + && (int)$config['port'] == $config['port'] + && $config['port'] > 0 + )) { + throw new ExtensionException( + $this, + 'The "port" configuration option must be an integer greater than 0.' + ); + } + + /** @var array{port?: number} $config */ + return (int)($config['port'] ?? 8906); + } + + public function stop(OutputInterface $output): void + { + $pidFile = $this->getPidFile(); + $mysqlServerPid = (int)file_get_contents($pidFile); + + if (!$mysqlServerPid) { + $output->writeln('MySQL server not running.'); + return; + } + + $output->write("Stopping MySQL server with PID $mysqlServerPid ...", false); + $this->kill($mysqlServerPid); + $this->removePidFile($pidFile); + $output->write(' ok', true); + } + + public function getPrettyName(): string + { + return 'MySQL Community Server'; + } + + /** + * @return array{ + * running: string, + * pidFile: string, + * port: int + * } + * @throws ExtensionException + */ + public function getInfo(): array + { + $isRunning = is_file($this->getPidFile()); + + $info = [ + 'running' => $isRunning ? 'yes' : 'no', + 'pidFile' => Filesystem::relativePath(codecept_root_dir(), $this->getPidFile()), + 'host' => '127.0.0.1', + 'port' => $this->getPort(), + 'user' => $this->getUser(), + 'password' => $this->getPassword(), + 'root user' => 'root', + 'root password' => $this->getUser() === 'root' ? $this->getPassword() : '' + ]; + + if ($isRunning) { + $info['mysql command'] = $this->getCliConnectionCommandline(); + $info['mysql root command'] = $this->getRootCliConnectionCommandline(); + } + + return $info; + } + + private function getCliConnectionCommandline(): string + { + if ($this->getPassword() === '') { + return "mysql -h 127.0.0.1 -P {$this->getPort()} -u {$this->getUser()}"; + } + + return "mysql -h 127.0.0.1 -P {$this->getPort()} -u {$this->getUser()} -p '{$this->getPassword()}'"; + } + + private function getRootCliConnectionCommandline(): string + { + $rootPassword = $this->getUser() === 'root' ? $this->getPassword() : ''; + if ($rootPassword === '') { + return "mysql -h 127.0.0.1 -P {$this->getPort()} -u root"; + } + + return "mysql -h 127.0.0.1 -P {$this->getPort()} -u root -p '{$rootPassword}'"; + } + + private function getBinary(): ?string + { + $config = $this->config; + if (isset($config['binary']) && !(is_string($config['binary']) && is_executable($config['binary']))) { + throw new ExtensionException( + $this, + 'The "binary" configuration option must be an executable file.' + ); + } + + /** @var array{binary?: string} $config */ + return ($config['binary'] ?? null); + } + + private function getShareDir(?string $binary): ?string + { + /** @var array{shareDir?: string} $config */ + $config = $this->config; + if (isset($config['shareDir']) && !(is_string($config['shareDir']) && is_dir($config['shareDir']))) { + throw new ExtensionException( + $this, + 'The "shareDir" configuration option must be a directory.' + ); + } + + $shareDir = $config['shareDir'] ?? null; + + if ($binary && $shareDir === null) { + throw new ExtensionException( + $this, + 'The "shareDir" configuration option must be set when using a custom binary.' + ); + } + + return $shareDir; + } +} diff --git a/src/ManagedProcess/ChromeDriver.php b/src/ManagedProcess/ChromeDriver.php index e4e174c84..5e29ca66d 100644 --- a/src/ManagedProcess/ChromeDriver.php +++ b/src/ManagedProcess/ChromeDriver.php @@ -44,7 +44,7 @@ public function __construct( /** * @throws RuntimeException */ - public function doStart(): void + private function doStart(): void { $command = [$this->chromeDriverBinary, '--port=' . $this->port, ...$this->arguments]; $process = new Process($command); diff --git a/src/ManagedProcess/ManagedProcessTrait.php b/src/ManagedProcess/ManagedProcessTrait.php index 4d2bddd93..bd06e3502 100644 --- a/src/ManagedProcess/ManagedProcessTrait.php +++ b/src/ManagedProcess/ManagedProcessTrait.php @@ -43,8 +43,9 @@ public function stop(): ?int $exitCode = $process->stop(); if (is_file(static::getPidFile()) && !unlink(static::getPidFile())) { + $pidFile = static::getPidFile(); throw new RuntimeException( - "Could not remove PID file '{static::getPidFile(}'.", + "Could not remove PID file {$pidFile}.", ManagedProcessInterface::ERR_PID_FILE_DELETE ); } diff --git a/src/ManagedProcess/MysqlServer.php b/src/ManagedProcess/MysqlServer.php new file mode 100644 index 000000000..5d67f775d --- /dev/null +++ b/src/ManagedProcess/MysqlServer.php @@ -0,0 +1,547 @@ +usingCustomBinary = true; + } + + if ($binary !== null && !is_executable($binary)) { + throw new RuntimeException( + "MySQL Server binary $binary does not exist.", + ManagedProcessInterface::ERR_BINARY_NOT_FOUND + ); + } + + if ($this->usingCustomBinary) { + if (!($shareDir && is_dir($shareDir))) { + throw new RuntimeException( + "MySQL Server share directory $shareDir does not exist.", + self::ERR_CUSTOM_BINARY_SHARE_DIR_PATH + ); + } + + $this->customShareDir = $shareDir; + } + + $this->directory = $directory ?? (FS::cacheDir() . '/mysql-server'); + if (!is_dir($this->directory) && !mkdir($this->directory, 0777, true) && !is_dir($this->directory)) { + throw new RuntimeException( + "Could not create directory for MySQL Server at $this->directory", + self::ERR_MYSQL_DIR_NOT_CREATED + ); + } + $this->binary = $binary; + $this->machineInformation = new MachineInformation(); + $this->pidFile = self::getPidFile(); + } + + public function setMachineInformation(MachineInformation $machineInformation): void + { + $this->machineInformation = $machineInformation; + } + + public function getDataDir(bool $normalize = false): string + { + $isWin = $this->machineInformation->isWindows(); + $dataDir = $this->directory . '/data'; + return $isWin && !$normalize ? + str_replace('/', '\\', $dataDir) + : $dataDir; + } + + public function getPidFilePath(bool $normalize = false): string + { + $isWin = $this->machineInformation->isWindows(); + return $isWin && !$normalize ? + str_replace('/', '\\', $this->pidFile) + : $this->pidFile; + } + + /** + * @return array + */ + private function getInitializeCommand(bool $normalize = false): array + { + $dataDir = $this->getDataDir($normalize); + return [ + $this->getBinary($normalize), + '--no-defaults', + '--initialize-insecure', + '--innodb-flush-method=nosync', + '--datadir=' . $dataDir, + '--pid-file=' . $this->getPidFilePath($normalize) + ]; + } + + public function initializeServer(): void + { + if (is_dir($this->getDataDir(true))) { + return; + } + + $this->output?->writeln("Initializing MySQL Server ...", OutputInterface::VERBOSITY_DEBUG); + $process = new Process($this->getInitializeCommand()); + $process->mustRun(); + $this->output?->writeln('MySQL Server initialized.', OutputInterface::VERBOSITY_DEBUG); + } + + public function getExtractedPath(bool $normalize = false): string + { + if ($this->usingCustomBinary) { + throw new RuntimeException( + "Extracted path not available when using a custom binary.", + MysqlServer::ERR_CUSTOM_BINARY_EXTRACTED_PATH + ); + } + + $mysqlServerArchivePath = $this->getArchivePath($normalize); + $isWin = $this->machineInformation->isWindows(); + $normalizedMysqlServerArchivePath = $isWin && !$normalize ? + str_replace('\\', '/', $mysqlServerArchivePath) + : $mysqlServerArchivePath; + $archiveExtension = match ($this->machineInformation->getOperatingSystem()) { + MachineInformation::OS_DARWIN => '.tar.gz', + MachineInformation::OS_WINDOWS => '.zip', + default => '.tar.xz', + }; + $extractedPath = dirname($normalizedMysqlServerArchivePath) . '/' . basename( + $normalizedMysqlServerArchivePath, + $archiveExtension + ); + + return !$normalize && $this->machineInformation->isWindows() ? + str_replace('/', '\\', $extractedPath) + : $extractedPath; + } + + public function getShareDir(bool $normalize = false): string + { + if ($this->customShareDir) { + return $normalize ? FS::normalizePath($this->customShareDir) : $this->customShareDir; + } + + $shareDir = $this->getExtractedPath(true) . '/share'; + return !$normalize && $this->machineInformation->isWindows() ? + str_replace('/', '\\', $shareDir) + : $shareDir; + } + + public function getSocketPath(bool $normalize = false): string + { + $path = $this->directory . '/mysql.sock'; + return !$normalize && $this->machineInformation->isWindows() ? + str_replace('/', '\\', $path) + : $path; + } + + /** + * @return array + */ + private function getStartCommand(int $port, bool $normalize = false): array + { + return [ + $this->getBinaryPath($normalize), + '--datadir=' . $this->getDataDir(), + '--skip-mysqlx', + '--default-time-zone=+00:00', + '--innodb-flush-method=nosync', + '--innodb-flush-log-at-trx-commit=0', + '--innodb-doublewrite=0', + '--bind-address=localhost', + '--lc-messages-dir=' . $this->getShareDir($normalize), + '--socket=' . $this->getSocketPath($normalize), + '--port=' . $port, + '--pid-file=' . $this->getPidFilePath($normalize) + ]; + } + + private function startServer(int $port): Process + { + $this->initializeServer(); + $dataDir = $this->getDataDir(true); + if (!is_dir($dataDir) && !(mkdir($dataDir, 0755, true) && is_dir($dataDir))) { + throw new RuntimeException( + "Could not create directory for MySQL Server data at $dataDir", + self::ERR_MYSQL_DATA_DIR_NOT_CREATED + ); + } + $startCommand = $this->getStartCommand($port); + $process = new Process($startCommand); + $process->createNewConsole(); + try { + // Try to start the server 40 times, 10 seconds apart. + $tries = 40; + $process->start(); + while (!$this->getRootPDOOrNot() && $tries--) { + // Sleep a .25 seconds to allow the server to start. + usleep(250000); + } + } catch (\Exception $e) { + throw new RuntimeException( + "Could not start MySQL Server at $this->directory\n" . $e->getMessage(), + self::ERR_MYSQL_SERVER_START_FAILED, + $e + ); + } + return $process; + } + + private function getRootPDOOrNot(): ?\PDO + { + try { + return $this->getRootPDO(); + } catch (\Throwable) { + return null; + } + } + + public function getRootPassword(): string + { + return $this->getUser() === 'root' ? $this->password : ''; + } + + /** + * @throws PDOException + */ + public function getRootPDO(): \PDO + { + try { + return new \PDO( + "mysql:host=127.0.0.1;port={$this->port}", + 'root', + $this->getRootPassword(), + [ + \PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION, + \PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC, + \PDO::ATTR_EMULATE_PREPARES => false, + ] + ); + } catch (\PDOException $e) { + // Connection with the set password failed, the server might not have been initialized yet + // and still use the default, insecure, empty root password. + return new \PDO( + "mysql:host=127.0.0.1;port={$this->port}", + 'root', + '', + [ + \PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION, + \PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC, + \PDO::ATTR_EMULATE_PREPARES => false, + ] + ); + } + } + + public function setDatabaseName(string $databaseName): void + { + $this->database = $databaseName; + } + + public function setUserName(string $username): void + { + $this->user = $username; + } + + public function setPassword(string $password): void + { + $this->password = $password; + } + + public function getDatabase(): string + { + return $this->database; + } + + public function getUser(): string + { + return $this->user; + } + + public function getPassword(): string + { + return $this->password; + } + + private function createDefaultData(): void + { + $pdo = $this->getRootPDO(); + $user = $this->getUser(); + $password = $this->getPassword(); + if ($user === 'root' && $password !== '') { + $pdo->exec("ALTER USER 'root'@'localhost' IDENTIFIED BY '{$this->getPassword()}'"); + } + $databaseName = $this->getDatabase(); + $pdo->exec("CREATE DATABASE IF NOT EXISTS `$databaseName`"); + if ($user !== 'root') { + $pdo->exec("CREATE USER IF NOT EXISTS '$user'@'%' IDENTIFIED BY '$password'"); + $pdo->exec("GRANT ALL PRIVILEGES ON `$databaseName`.* TO '$user'@'%'"); + } + $pdo->exec("FLUSH PRIVILEGES"); + } + + private function doStart(): void + { + $this->process = $this->startServer($this->port ?? self::PORT_DEFAULT); + $this->createDefaultData(); + } + + public function getBinaryPath(bool $normalize = false): string + { + if ($this->binary !== null) { + return !$normalize && $this->machineInformation->isWindows() ? + str_replace('/', '\\', $this->binary) + : $this->binary; + } + + $isWin = $this->machineInformation->isWindows(); + $binaryPath = implode('/', [ + $this->getExtractedPath(true), + 'bin', + ($isWin ? 'mysqld.exe' : 'mysqld') + ]); + + return !$normalize && $isWin ? + str_replace('/', '\\', $binaryPath) + : $binaryPath; + } + + public function getBinary(bool $normalize = false): string + { + if ($this->binary !== null) { + return !$normalize && $this->machineInformation->isWindows() ? + str_replace('/', '\\', $this->binary) + : $this->binary; + } + + $mysqlServerArchivePath = $this->getArchivePath(true); + $mysqlServerBinaryPath = $this->getBinaryPath(true); + + if (is_file($mysqlServerBinaryPath)) { + return !$normalize && $this->machineInformation->isWindows() ? + str_replace('/', '\\', $mysqlServerBinaryPath) + : $mysqlServerBinaryPath; + } + + if (!is_file($mysqlServerArchivePath)) { + $this->downloadMysqlServerArchive(); + } + + if (!is_file($mysqlServerBinaryPath)) { + $this->extractMysqlServerArchive(); + } + + if (!is_file($mysqlServerBinaryPath)) { + throw new RuntimeException( + "Could not find MySQL Server binary at $mysqlServerBinaryPath", + self::ERR_BINARY_NOT_FOUND + ); + } + + if (!$normalize && $this->machineInformation->isWindows()) { + $mysqlServerBinaryPath = str_replace('/', '\\', $mysqlServerBinaryPath); + } + + return $mysqlServerBinaryPath; + } + + public function getArchiveUrl(): string + { + $operatingSystem = $this->machineInformation->getOperatingSystem(); + if (!in_array($operatingSystem, [ + MachineInformation::OS_DARWIN, + MachineInformation::OS_LINUX, + MachineInformation::OS_WINDOWS + ], true)) { + throw new RuntimeException( + "Unsupported OS for MySQL Server binary.", + self::ERR_OS_NOT_SUPPORTED + ); + }; + + $architecture = $this->machineInformation->getArchitecture(); + if (!in_array($architecture, [MachineInformation::ARCH_X86_64, MachineInformation::ARCH_ARM64], true)) { + throw new RuntimeException( + "Unsupported architecture for MySQL Server binary.", + self::ERR_ARCH_NOT_SUPPORTED + ); + } + + if ($operatingSystem === MachineInformation::OS_WINDOWS && $architecture === MachineInformation::ARCH_ARM64) { + throw new RuntimeException( + "Windows ARM64 is not (yet) supported by MySQL Server.\n" . + "Use MySQL through the DockerComposeController extension.\n" . + "See: https://wpbrowser.wptestkit.dev/extensions/DockerComposeController/\n" . + "See: https://hub.docker.com/_/mysql", + self::ERR_WINDOWS_ARM64_NOT_SUPPORTED, + ); + } + + if ($operatingSystem === MachineInformation::OS_DARWIN) { + return $architecture === 'arm64' ? + 'https://dev.mysql.com/get/Downloads/MySQL-8.4/mysql-8.4.2-macos14-arm64.tar.gz' + : 'https://dev.mysql.com/get/Downloads/MySQL-8.4/mysql-8.4.2-macos14-x86_64.tar.gz'; + } + + if ($operatingSystem === MachineInformation::OS_LINUX) { + return $architecture === 'arm64' ? + 'https://dev.mysql.com/get/Downloads/MySQL-8.4/mysql-8.4.2-linux-glibc2.17-aarch64-minimal.tar.xz' + : 'https://dev.mysql.com/get/Downloads/MySQL-8.4/mysql-8.4.2-linux-glibc2.17-x86_64-minimal.tar.xz'; + } + + return 'https://dev.mysql.com/get/Downloads/MySQL-8.4/mysql-8.4.2-winx64.zip'; + } + + public function getArchivePath(bool $normalize = false): string + { + $path = $this->directory . '/' . basename($this->getArchiveUrl()); + return $this->machineInformation->isWindows() && !$normalize ? + str_replace('/', '\\', $path) + : $path; + } + + private function downloadMysqlServerArchive(): void + { + $archiveUrl = $this->getArchiveUrl(); + $archivePath = $this->getArchivePath(true); + + try { + $this->output?->writeln( + "Downloading MySQL Server archive from $archiveUrl ...", + OutputInterface::VERBOSITY_DEBUG + ); + Download::fileFromUrl($archiveUrl, $archivePath); + $this->output?->writeln('Downloaded MySQL Server archive.', OutputInterface::VERBOSITY_DEBUG); + } catch (\Exception $e) { + throw new RuntimeException( + "Could not download MySQL Server archive from $archiveUrl to $archivePath: " . $e->getMessage(), + self::ERR_MYSQL_ARCHIVE_DOWNLOAD_FAILED, + $e + ); + } + } + + /** + * @throws RuntimeException + */ + private function extractArchiveWithPhar(string $archivePath, string $directory): void + { + $memoryLimit = ini_set('memory_limit', '1G'); + try { + $extracted = (new PharData($archivePath))->extractTo($directory, null, true); + } catch (\Exception $e) { + throw new RuntimeException( + "Could not extract MySQL Server archive from $archivePath to " + . $directory . + "\n" . $e->getMessage(), + self::ERR_MYSQL_ARCHIVE_EXTRACTION_FAILED + ); + } finally { + ini_set('memory_limit', (string)$memoryLimit); + } + } + + /** + * @throws ProcessFailedException + */ + private function extractArchiveWithTarCommand(string $archivePath, string $directory): void + { + $extension = pathinfo($archivePath, PATHINFO_EXTENSION); + $flags = $extension === 'xz' ? '-xf' : '-xzf'; + $process = new Process(['tar', $flags, $archivePath, '-C', $directory]); + $process->mustRun(); + } + + private function extractMysqlServerArchive(): void + { + $mysqlServerArchivePath = $this->getArchivePath(true); + + $this->output?->writeln( + "Extracting MySQL Server archive from $mysqlServerArchivePath ...", + OutputInterface::VERBOSITY_DEBUG + ); + $directory = $this->directory; + try { + if ($this->machineInformation->isWindows()) { + $this->extractArchiveWithPhar($mysqlServerArchivePath, $directory); + } else { + $this->extractArchiveWithTarCommand($mysqlServerArchivePath, $directory); + } + } catch (\Throwable $e) { + throw new RuntimeException( + "Could not extract MySQL Server archive from $mysqlServerArchivePath to " + . $directory . + "\n" . $e->getMessage(), + self::ERR_MYSQL_ARCHIVE_EXTRACTION_FAILED + ); + } + $this->output?->writeln('Extracted MySQL Server archive.', OutputInterface::VERBOSITY_DEBUG); + } + + public function isUsingCustomBinary(): bool + { + return $this->usingCustomBinary; + } + + public function setOutput(OutputInterface $output = null): void + { + $this->output = $output; + } + + public function getDirectory(bool $normalize = false): string + { + return !$normalize && $this->machineInformation->isWindows() ? + str_replace('/', '\\', $this->directory) + : $this->directory; + } +} diff --git a/src/ManagedProcess/PhpBuiltInServer.php b/src/ManagedProcess/PhpBuiltInServer.php index 1f24f631c..14e39d9a8 100644 --- a/src/ManagedProcess/PhpBuiltInServer.php +++ b/src/ManagedProcess/PhpBuiltInServer.php @@ -47,7 +47,7 @@ public function __construct(private string $docRoot, private int $port = 0, priv /** * @throws RuntimeException */ - public function doStart(): void + private function doStart(): void { $routerPathname = dirname(__DIR__, 2) . '/includes/cli-server/router.php'; $command = [ diff --git a/src/Traits/UopzFunctions.php b/src/Traits/UopzFunctions.php index 4269bdf51..69da255da 100644 --- a/src/Traits/UopzFunctions.php +++ b/src/Traits/UopzFunctions.php @@ -63,7 +63,7 @@ trait UopzFunctions private static ?bool $uopzAllowExit = null; - protected function setFunctionReturn(string $function, mixed $value, bool $execute = false): void + protected function setFunctionReturn(string $function, mixed $value, bool $execute = false): Closure { if (!function_exists('uopz_set_return')) { $this->markTestSkipped('This test requires the uopz extension'); @@ -71,6 +71,10 @@ protected function setFunctionReturn(string $function, mixed $value, bool $execu uopz_set_return($function, $value, $execute); self::$uopzSetFunctionReturns[$function] = true; + + return function () use ($function) { + $this->unsetFunctionReturn($function); + }; } protected function unsetFunctionReturn(string $function): void @@ -83,11 +87,15 @@ protected function unsetFunctionReturn(string $function): void unset(self::$uopzSetFunctionReturns[$function]); } - protected function setMethodReturn(string $class, string $method, mixed $value, bool $execute = false): void + protected function setMethodReturn(string $class, string $method, mixed $value, bool $execute = false): Closure { $classAndMethod = "$class::$method"; uopz_set_return($class, $method, $value, $execute); self::$uopzSetFunctionReturns[$classAndMethod] = true; + + return function () use ($class, $method) { + $this->unsetMethodReturn($class, $method); + }; } protected function unsetMethodReturn(string $class, string $method): void @@ -102,7 +110,7 @@ protected function unsetMethodReturn(string $class, string $method): void unset(self::$uopzSetFunctionReturns[$classAndMethod]); } - protected function setFunctionHook(string $function, Closure $hook): void + protected function setFunctionHook(string $function, Closure $hook): Closure { if (!function_exists('uopz_set_hook')) { $this->markTestSkipped('This test requires the uopz extension'); @@ -110,6 +118,10 @@ protected function setFunctionHook(string $function, Closure $hook): void uopz_set_hook($function, $hook); self::$uopzSetFunctionHooks[$function] = true; + + return function () use ($function) { + $this->unsetFunctionHook($function); + }; } protected function unsetFunctionHook(string $function): void @@ -122,7 +134,7 @@ protected function unsetFunctionHook(string $function): void unset(self::$uopzSetFunctionHooks[$function]); } - protected function setMethodHook(string $class, string $method, Closure $hook): void + protected function setMethodHook(string $class, string $method, Closure $hook): Closure { if (!function_exists('uopz_set_hook')) { $this->markTestSkipped('This test requires the uopz extension'); @@ -131,6 +143,10 @@ protected function setMethodHook(string $class, string $method, Closure $hook): $classAndMethod = "$class::$method"; uopz_set_hook($class, $method, $hook); self::$uopzSetFunctionHooks[$classAndMethod] = true; + + return function () use ($class, $method) { + $this->unsetMethodHook($class, $method); + }; } protected function unsetMethodHook(string $class, string $method): void @@ -145,7 +161,7 @@ protected function unsetMethodHook(string $class, string $method): void unset(self::$uopzSetFunctionHooks[$classAndMethod]); } - protected function setConstant(string $constant, mixed $value): void + protected function setConstant(string $constant, mixed $value): Closure { if (!function_exists('uopz_redefine')) { $this->markTestSkipped('This test requires the uopz extension'); @@ -158,6 +174,10 @@ protected function setConstant(string $constant, mixed $value): void uopz_redefine($constant, $value); } self::$uopzSetConstants[$constant] = $previousValue; + + return function () use ($constant) { + $this->unsetConstant($constant); + }; } protected function unsetConstant(string $constant): void @@ -176,7 +196,7 @@ protected function unsetConstant(string $constant): void unset(self::$uopzSetConstants[$constant]); } - protected function setClassConstant(string $class, string $constant, mixed $value): void + protected function setClassConstant(string $class, string $constant, mixed $value): Closure { if (!function_exists('uopz_redefine')) { $this->markTestSkipped('This test requires the uopz extension'); @@ -187,6 +207,10 @@ protected function setClassConstant(string $class, string $constant, mixed $valu : '__NOT_PREVIOUSLY_DEFINED__'; uopz_redefine($class, $constant, $value); self::$uopzSetConstants["$class::$constant"] = $previousValue; + + return function () use ($class, $constant) { + $this->unsetClassConstant($class, $constant); + }; } protected function unsetClassConstant(string $class, string $constant): void @@ -205,7 +229,7 @@ protected function unsetClassConstant(string $class, string $constant): void unset(self::$uopzSetConstants["$class::$constant"]); } - protected function setClassMock(string $class, mixed $mock): void + protected function setClassMock(string $class, mixed $mock): Closure { if (!function_exists('uopz_set_mock')) { $this->markTestSkipped('This test requires the uopz extension'); @@ -213,6 +237,10 @@ protected function setClassMock(string $class, mixed $mock): void uopz_set_mock($class, $mock); self::$uopzSetClassMocks[$class] = true; + + return function () use ($class) { + $this->unsetClassMock($class); + }; } protected function unsetClassMock(string $class): void @@ -225,7 +253,7 @@ protected function unsetClassMock(string $class): void unset(self::$uopzSetClassMocks[$class]); } - protected function unsetClassFinalAttribute(string $class): void + protected function unsetClassFinalAttribute(string $class): Closure { if (!function_exists('uopz_unset_return')) { $this->markTestSkipped('This test requires the uopz extension'); @@ -234,6 +262,10 @@ protected function unsetClassFinalAttribute(string $class): void $flags = uopz_flags($class, ''); uopz_flags($class, '', $flags & ~ZEND_ACC_FINAL); self::$uopzUnsetClassFinalAttribute[$class] = true; + + return function () use ($class) { + $this->resetClassFinalAttribute($class); + }; } protected function resetClassFinalAttribute(string $class): void @@ -247,7 +279,7 @@ protected function resetClassFinalAttribute(string $class): void unset(self::$uopzUnsetClassFinalAttribute[$class]); } - protected function unsetMethodFinalAttribute(string $class, string $method): void + protected function unsetMethodFinalAttribute(string $class, string $method): Closure { if (!function_exists('uopz_unset_return')) { $this->markTestSkipped('This test requires the uopz extension'); @@ -256,6 +288,10 @@ protected function unsetMethodFinalAttribute(string $class, string $method): voi $flags = uopz_flags($class, $method); uopz_flags($class, $method, $flags & ~ZEND_ACC_FINAL); self::$uopzUnsetClassMethodFinalAttribute["$class::$method"] = true; + + return function () use ($class, $method) { + $this->resetMethodFinalAttribute($class, $method); + }; } protected function resetMethodFinalAttribute(string $class, string $method): void @@ -270,7 +306,7 @@ protected function resetMethodFinalAttribute(string $class, string $method): voi unset(self::$uopzUnsetClassMethodFinalAttribute[$classAndMethod]); } - protected function addClassMethod(string $class, string $method, Closure $closure, bool $static = false): void + protected function addClassMethod(string $class, string $method, Closure $closure, bool $static = false): Closure { if (!function_exists('uopz_add_function')) { $this->markTestSkipped('This test requires the uopz extension'); @@ -282,6 +318,10 @@ protected function addClassMethod(string $class, string $method, Closure $closur } uopz_add_function($class, $method, $closure, $flags); self::$uopzAddClassMethods["$class::$method"] = true; + + return function () use ($class, $method) { + $this->removeClassMethod($class, $method); + }; } protected function removeClassMethod(string $class, string $method): void @@ -299,7 +339,7 @@ protected function setObjectProperty( string|object $classOrObject, string $property, mixed $value - ): void { + ): Closure { if (!function_exists('uopz_set_property')) { $this->markTestSkipped('This test requires the uopz extension'); } @@ -308,6 +348,10 @@ protected function setObjectProperty( uopz_set_property($classOrObject, $property, $value); $id = is_string($classOrObject) ? $classOrObject : spl_object_hash($classOrObject); self::$uopzSetObjectProperties["$id::$property"] = [$previousValue, $classOrObject]; + + return function () use ($classOrObject, $property) { + $this->resetObjectProperty($classOrObject, $property); + }; } protected function getObjectProperty(string|object $classOrObject, string $property): mixed @@ -335,7 +379,7 @@ protected function resetObjectProperty(string|object $classOrObject, string $pro /** * @param array $values */ - protected function setMethodStaticVariables(string $class, string $method, array $values): void + protected function setMethodStaticVariables(string $class, string $method, array $values): Closure { if (!function_exists('uopz_set_static')) { $this->markTestSkipped('This test requires the uopz extension'); @@ -350,6 +394,10 @@ protected function setMethodStaticVariables(string $class, string $method, array } uopz_set_static($class, $method, $values); + + return function () use ($class, $method) { + $this->resetMethodStaticVariables($class, $method); + }; } /** @@ -398,7 +446,7 @@ protected function getFunctionStaticVariables(string $function): array /** * @param array $values */ - protected function setFunctionStaticVariables(string $function, array $values): void + protected function setFunctionStaticVariables(string $function, array $values): Closure { if (!function_exists('uopz_set_static')) { $this->markTestSkipped('This test requires the uopz extension'); @@ -413,6 +461,10 @@ protected function setFunctionStaticVariables(string $function, array $values): } uopz_set_static($function, array_merge($currentValues, $values)); + + return function () use ($function) { + $this->resetFunctionStaticVariables($function); + }; } protected function resetFunctionStaticVariables(string $function): void @@ -426,7 +478,7 @@ protected function resetFunctionStaticVariables(string $function): void unset(self::$uopzSetFunctionStaticVariables[$function]); } - protected function addFunction(string $function, Closure $handler): void + protected function addFunction(string $function, Closure $handler): Closure { if (!function_exists('uopz_add_function')) { $this->markTestSkipped('This test requires the uopz extension'); @@ -434,6 +486,10 @@ protected function addFunction(string $function, Closure $handler): void self::$uopzAddedFunctions[$function] = true; uopz_add_function($function, $handler); + + return function () use ($function) { + $this->removeFunction($function); + }; } protected function removeFunction(string $function): void diff --git a/src/Utils/Filesystem.php b/src/Utils/Filesystem.php index 185e6106e..79ddc167d 100644 --- a/src/Utils/Filesystem.php +++ b/src/Utils/Filesystem.php @@ -427,4 +427,58 @@ private static function symfonyFilesystem(): SymfonyFilesystem self::$symfonyFilesystem ??= new SymfonyFilesystem(); return self::$symfonyFilesystem; } + + /** + * Copy of `wp_is_stream` from `wp-includes/functions.php`. + */ + public static function isStream(string $path):bool + { + $scheme_separator = strpos($path, '://'); + + if (false === $scheme_separator) { + // $path isn't a stream. + return false; + } + + $stream = substr($path, 0, $scheme_separator); + + return in_array($stream, stream_get_wrappers(), true); + } + + /** + * Copy of `wp_normalize_path` from `wp-includes/functions.php`. + */ + public static function normalizePath(string $path):string + { + if ($path === '') { + return ''; + } + + /** @var non-empty-string $path */ + + $wrapper = ''; + + if (self::isStream($path)) { + list( $wrapper, $path ) = explode('://', $path, 2); + + $wrapper .= '://'; + } + + // Standardize all paths to use '/'. + $path = str_replace('\\', '/', $path); + + // Replace multiple slashes down to a singular, allowing for network shares having two slashes. + $path = preg_replace('|(?<=.)/+|', '/', $path); + + if (empty($path)) { + return (string)$path; + } + + // Windows paths should uppercase the drive letter. + if (':' === $path[1]) { + $path = ucfirst($path); + } + + return $wrapper . $path; + } } diff --git a/src/Utils/MachineInformation.php b/src/Utils/MachineInformation.php new file mode 100644 index 000000000..17a5e072e --- /dev/null +++ b/src/Utils/MachineInformation.php @@ -0,0 +1,49 @@ +operatingSystem = $operatingSystem ?? match (strtolower(substr(php_uname('s'), 0, 3))) { + 'dar' => self::OS_DARWIN, + 'lin' => self::OS_LINUX, + 'win' => self::OS_WINDOWS, + default => self::OS_UNKNOWN + }; + + $this->architecture = $architecture ?? match (strtolower(php_uname('m'))) { + 'x86_64', 'amd64' => self::ARCH_X86_64, + 'arm64', 'aarch64' => self::ARCH_ARM64, + default => self::ARCH_UNKNOWN + }; + } + + public function getOperatingSystem(): string + { + return $this->operatingSystem; + } + + public function getArchitecture(): string + { + return $this->architecture; + } + + public function isWindows():bool + { + return $this->operatingSystem === self::OS_WINDOWS; + } +} diff --git a/tests/_data/mysql-server/mock-archives/mysql-8.4.2-linux-glibc2.17-aarch64-minimal.tar.xz b/tests/_data/mysql-server/mock-archives/mysql-8.4.2-linux-glibc2.17-aarch64-minimal.tar.xz new file mode 100644 index 0000000000000000000000000000000000000000..866de05f858390b8494e8364858be723c914b648 GIT binary patch literal 816 zcmV-01JC^ZH+ooF000E$*0e?f03iVu0001VFXf})S^om?T>v(oO0FT(&d~}>1O|!x z*ufhjVaKXzm9w^lj{4#GLW|ZuzIHrtm%pFoA1G2(=?|?zeHF~oQBe&|06{BLuvoK- zd!5ds1(bG}Fk7ryNm7@;CGjp8mNsPML~if&775iF%+F+Jxzw`PEabc`nPb60J6R#%`{)ba^nw;{{xO{t8%bK6pH2~>}6D~O-+@`bTq_cPaCI8G+t9f#;MO+K1)T>e| zc}D|LHy;`f!e29_*g@Q1pl^7(;>P(4VYfFkZ?cyd5T{z8knq!6G^M6x4M-c>Rqd1R zkL~wdiG`t;heciE_wc08Li_3EV&a(F&X;xT6Q4AVQGzTygWidZ>CVQ zhukPPbU{Jcn|<3x6M6&00^{Gh%N*3m9!J94AGrmJ_Q$x>$>)mAfq;x=-bn)^ z&Igryo0X06(+4CJVis&_eUC9#R`KRS4XJ^emfF?j|3ITvykSQ^O98qFBT0((?=$UM zC0a+iLDMgMDk)M54jRepSc#8c0c|NH*<-|=&Q|KfjV|8W2B|6o7}Kwu8Y2mlBG z00aRDPz0U;1JQPq0CB(rK+p{h000d!4H{?w007X)00Ru7Wj&;6KmY&$13&;700001 zpa2{IGz|dI&;S6_5YeW9000dPfB-Oxg$$ZyPZTgu)d8AljWlR99-|-t001;HG-P50 zNg$`GHjOk>)6{6xWDOcL^p8_ajWjd>!f0sGrXlKjfYGHEGc=p$=4A0OWRa1PftbM6 zHBD0{Mn*| zoHdH#IVzhfZ8a>tMvWa-3>&6_jU#7{tS2Mb6u2hRw zO{M1TUcRCW6#+#EqM>C%%2!QRkkq6sP?S<%D2o(?QBWJSSn}ng&Xo|jp+zA@B#J2N zGU~0o_bTipoPtM+MUOH?MyXKOQcp)ucksO!l#vvFo<9ZZ%b~!%(O$EWMAFk~WfNEw z4CQ=wJcNG7A zG0`m=>X7waS!h0$LlX%WbEH~Ec?(r;M1C`r#9z5jO=YcZ?5)qb`>lPLv2*-W1`f}8 zi5ZYH)Y4O&q{>*fq?D4CsTobyHK7Xj3Y;=vG2u$I)K z&B%IG(=(jPTMA)Pn(W?;@RXwl>js`O(IE&d0KkzdG6aS? zH>{Bwgn@*GmWKpT1xLNSWQY}n7o>!8KC5BQ;!#eMY;Tm~ofPN04qZ>N&F_83-le9p zlY%?4nVJ(}-)~Ko%g$b~-JTx&+i;(d=^R<_0V+tio4~aL z>M4w+!A!SAX&R6-6xAf9?Zq&ueUX^J*D6yjOO812g|rUoyhSI1KI2G;3UIrUDZ+$+ HC^`Lz3BuT& literal 0 HcmV?d00001 diff --git a/tests/_data/mysql-server/mock-archives/mysql-8.4.2-macos14-arm64.tar.gz b/tests/_data/mysql-server/mock-archives/mysql-8.4.2-macos14-arm64.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..0982baf81a10f87c7c28597faa1854f27c7f1660 GIT binary patch literal 904 zcmV;319$u%iwFP$lCNd}1MQkkQ`$fn$2VxFlwP>l>2WVT8h1CZ9GC$VM`rAsP^X@3 zgkTv-P+mIp#=Vywd+$f;*XW1nhAf&=lfalpl;=0I{v*4ax8>P~WdF|B!BuzLlvSB+ zJ65wdpz5~OcXV|JB?ckH&@?<%-B-d?Uj>+|x~3SES5Qo-PE-S7ExA(&XEYdEeSRw4 zQPUcZ96PKXZ`N){>_=S3wLTD>(D9c~<-Pi8ec0=V-jfC4_d{3J@bRZcVEh%GkH2AP z3c@6jD~w^y#~(d;@f2kcdTllF+dBS;1>R@q883{NeO|o($6kk7i&8#pm*qy|NNkR0 zy@1suJpSKV;{)4j+5Mfn&I57rukbnlulQ$FBO1b~d~w!1{uP3qVaKtH)L_go`CnZw zP~9YIp`hjXDAjilYKJFsyWO?U2gT2wR?BvCriKq|mGTj-DylF*5-lBmxJ-3;`}3&lc9LMOgTqFqMlrqHa=yihE(By{4dn6xDE z@F9sKOIe}f6@i3>mX^9?09YF~e93$Hzlv^u{s#cyaU*T+)%Pms`hnoz2E2RF|4Ubx=l{|f_`gyidi;~|fGy+y znvOps8q;9?HxDY2;kOVnp?tH=blXX`!y00000tTy2P0stTk@P7fo;|2U*004mh z3jhFMO^EaVOzT^BJ4W34KU0Elrt5!%!TMh+EaLyULVjldx!5iP{vWT$4_@Zq zD@r$?Z{4i(U-o-nY}ay|wwKG_RJVSg{QCYafr|vfSk(V>mw6yI{wbM@e{TW+|4$ms z`+taT$OWpHOlOLj(~Kye&rP@%%kOLD#>IzYW8ZCD)CM`V5a#6xbPB{c*mmxT==e_} e`^U$B(Ely+Khadk{{R30|C2ui0kIYUS^xkEiP6UZ literal 0 HcmV?d00001 diff --git a/tests/_data/mysql-server/mock-archives/mysql-8.4.2-macos14-x86_64.tar.gz b/tests/_data/mysql-server/mock-archives/mysql-8.4.2-macos14-x86_64.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..595e6c9d5f13c5bc2b1049d62faeb937de21d926 GIT binary patch literal 905 zcmV;419to$iwFQ5lCNd}1MQqmZ`v>z#~s>fj9s{N+R>LCo7#@^(nBSnr7BYQR#>&3 zx)j2O3Zdm?VmI!)?6~_r(!Rz%#2jMUj5Z0VSxU?EldONhc3v=#9}@pX=Ue}>y=kdj zWt$zl-tAMqIkJpXgKwdvAcUB@jwfpPN*MQ5f+;t2&7_2C9205~gCeXab_Nj)`vbct zPNhAp+k;`p32P^sHJcIpk=Jph4}?zm_^YStUiGv(==MTCl=%>!hrxOH_)~Ls{7q9g z5hlr8Vl*p0{^-ezrznHaYrBr$R`EwH@!mtvL}8-ri{iZ=c^#%L3i+^I7HhR5xjCBl z0+y4A_>0xFAqMXzpaC(0T+LoMVs`t|l%x7${xb(ugL#oNbBJpcdz007H}Ow`%u z_j?)u04yQi{D7qvq>g=6WPL{JdZ15UDwbN1I`&nO?J`o=rDmn(rDCZCsbgQ|q@~E4 z4@DkX$x4-v2ox-}u+Sv~z{;@ZOWw=>Wpw-fKL7xa8)bd3x>rJ1Hv~5ui1I10Jx1X_ z0y6#s#g~D;SZYD)*jM-=0001xHWBN8XDv4w57;99FN00|UxucG|0@k*#y=4c*dqQf zgJ_sAqH6@!e^UYcUn@;a{3qf8Tg3n6$3J5R1OHbV%=o_;u{4zu$|=)z1N>i)p1Ay< zga>R9|Ch}F8s(7xQ(|8Km#oj;R*?y8PuG0_00000SZ=`o1pq)8;Qs=E#|!ws00030 z7XSdjiV*AnnKWqJ=@@bA|BP!_>wmi8um2JF|EF}A$NzJQ{LKDqzFh|VKVFZYqRc*5 zRIcCOxL)VK=ykt3uI<(xFPFb5Z~Qs={o{KA7YT$hxBur3^FVC;Q!*3(Vgk^?|C0tc z{6B`NZd1)-2GgvZZbtchuETYw_`XuCoqsqs_ublgrJv*5VP2j6T~aRhXIRhlcPnuscs&p9aIF z72FJrEMFNJ7{CSvfDPne;6O2uMS_7r2D^cJF2V7^CHY0EgiL-rVOk#=H9hL_U%iD<0*Mj$DSSDv1#g-#kR8hex)e>YqL4Y`~1J$```77 zNAi=8zT2?+Lk`C>)>;|&^MdpB86$r!+#%Qd6=aMalhc`_vH{-g9LtMXG>-y985Ahk zgR254s}2mV_;_DOXaC>`eJl~77mpcoR+HAJfiPN}BqxX_rza#OeDL)N`@kR85zxRi zL4sLbK$>x*v_SI@QHL`O!4DM1$Y5eWr{*!;KF+;v0Ar4)z+`O}Zi0 zvd-+BVvB%NM{V-Dk~?M6P93sF^Z8rXl@v?QXSXU#Hi~`WWxjo5`~u~AcqeqIHeJJjm892FmLY6*xF-CMg3IDgrZ5 zw`2!XpQ55;fSSi~9))La!G}5o#C^}5zv``{al-Sew#F%a@3Y=#y!}!WQj$Iti`EKP zRyvu3b6Dt|=?q~&7lT|K;LXS+!i>8#hI#z2BZ!5iNFY>#!YqK5&d3E3sC0&bC5?gD zEkMnAuo?hFqvbqwn~)0Ko)Z`NqW Gf_MOB`h5NX literal 0 HcmV?d00001 diff --git a/tests/_data/uopz-test/functions.php b/tests/_data/uopz-test/functions.php index 6356ad46b..bba42f8e4 100644 --- a/tests/_data/uopz-test/functions.php +++ b/tests/_data/uopz-test/functions.php @@ -32,6 +32,15 @@ function withStaticVariable(): int $counter += $step; return $oldValue; } + + function withStaticVariableTwo(): int + { + static $counter = 0; + static $step = 2; + $oldValue = $counter; + $counter += $step; + return $oldValue; + } } namespace lucatume\WPBrowser\Acme\Project { diff --git a/tests/unit/lucatume/WPBrowser/Extension/MysqlServerControllerTest.php b/tests/unit/lucatume/WPBrowser/Extension/MysqlServerControllerTest.php new file mode 100644 index 000000000..9ff503121 --- /dev/null +++ b/tests/unit/lucatume/WPBrowser/Extension/MysqlServerControllerTest.php @@ -0,0 +1,633 @@ +setFunctionReturn('file_put_contents', function (string $file): void { + throw new AssertionFAiledError('Unexpected file_put_contents call for ' . $file); + }); + + $this->setClassMock(Process::class, $this->makeEmptyClass(Process::class, [ + '__construct' => function (array $command) { + throw new AssertionFAiledError('Unexpected Process::__construct call for ' . print_r($command, true)); + } + ])); + + $this->setClassMock(Download::class, $this->makeEmpty(Download::class, [ + 'fileFromUrl' => function (string $url, string $file): void { + throw new AssertionFAiledError("Unexpected Download::fileFromUrl call for URL $url and file $file"); + } + ])); + + $pidFile = (new MysqlServerController([], []))->getPidFile(); + $this->setFunctionReturn('is_file', function (string $file) use ($pidFile): bool { + return $file === $pidFile ? false : is_file($file); + }, true); + } + + public function invalidPortDataProvider(): array + { + return [ + 'string' => ['string'], + 'float' => [1.1], + 'negative' => [-1], + 'zero' => [0], + 'empty string' => [''], + ]; + } + + /** + * @dataProvider invalidPortDataProvider + */ + public function testStartThrowsForInvalidPort($invalidPort):void{ + $config = ['port' => $invalidPort]; + $options = []; + + $this->expectException(ExtensionException::class); + $this->expectExceptionMessage('The "port" configuration option must be an integer greater than 0.'); + + $controller = new MysqlServerController($config, $options); + $controller->start(new NullOutput()); + } + + public function notAStringDataProvider(): array + { + return [ + 'float' => [1.1], + 'negative' => [-1], + 'zero' => [0], + 'empty string' => [''], + ]; + } + + /** + * @dataProvider notAStringDataProvider + */ + public function testStartThrowsForInvalidDatabase($invalidDatabase):void{ + $config = ['database' => $invalidDatabase]; + $options = []; + + $this->expectException(ExtensionException::class); + $this->expectExceptionMessage('The "database" configuration option must be a string.'); + + $controller = new MysqlServerController($config, $options); + $controller->start(new NullOutput()); + } + + /** + * @dataProvider notAStringDataProvider + */ + public function testThrowsForInvalidUser($invalidUser):void{ + $config = ['user' => $invalidUser]; + $options = []; + + $this->expectException(ExtensionException::class); + $this->expectExceptionMessage('The "user" configuration option must be a string.'); + + $controller = new MysqlServerController($config, $options); + $controller->start(new NullOutput()); + } + + public function invalidPasswordDataProvider(): array + { + return [ + 'array' => [[]], + 'float' => [1.1], + ]; + } + + /** + * @dataProvider invalidPasswordDataProvider + */ + public function testThrowsForInvalidPassword($invalidPassword):void{ + $config = ['password' => $invalidPassword]; + $options = []; + + $this->expectException(ExtensionException::class); + $this->expectExceptionMessage('The "password" configuration option must be a string.'); + + $controller = new MysqlServerController($config, $options); + $controller->start(new NullOutput()); + } + + public function testStartWithDefaults(): void + { + $config = []; + $options = []; + $output = new BufferedOutput(); + $this->setClassMock( + MysqlServer::class, + $this->makeEmptyClass(MysqlServer::class, [ + '__construct' => function (...$args) { + $this->assertEquals([ + codecept_output_dir('_mysql_server'), + MysqlServer::PORT_DEFAULT, + 'wordpress', + 'wordpress', + 'wordpress', + null, + null + ], $args); + }, + 'start' => null + ]) + ); + + $controller = new MysqlServerController($config, $options); + $controller->start($output); + $port = MysqlServer::PORT_DEFAULT; + $this->assertEquals($port, $controller->getPort()); + $this->assertEquals("Starting MySQL server on port {$port} ... ok\n", $output->fetch()); + $this->assertEquals(codecept_output_dir('mysql-server.pid'), $controller->getPidFile()); + } + + public function testStartWithCustomPort(): void + { + $config = ['port' => 2389]; + $options = []; + $output = new BufferedOutput(); + $this->setClassMock( + MysqlServer::class, + $this->makeEmptyClass(MysqlServer::class, [ + '__construct' => function (...$args) { + $this->assertEquals([ + codecept_output_dir('_mysql_server'), + 2389, + 'wordpress', + 'wordpress', + 'wordpress', + null, + null + ], $args); + }, + 'start' => null + ]) + ); + + $controller = new MysqlServerController($config, $options); + $controller->start($output); + $this->assertEquals(2389, $controller->getPort()); + $this->assertEquals("Starting MySQL server on port 2389 ... ok\n", $output->fetch()); + } + + public function testStartWithCustomDatabaseUserNamePassword(): void + { + $config = ['database' => 'test', 'user' => 'luca', 'password' => 'secret']; + $options = []; + $output = new BufferedOutput(); + $this->setClassMock( + MysqlServer::class, + $this->makeEmptyClass(MysqlServer::class, [ + '__construct' => function (...$args) { + $this->assertEquals([ + codecept_output_dir('_mysql_server'), + MysqlServer::PORT_DEFAULT, + 'test', + 'luca', + 'secret', + null, + null + ], $args); + }, + 'start' => null + ]) + ); + + $controller = new MysqlServerController($config, $options); + $controller->start($output); + $port = MysqlServer::PORT_DEFAULT; + $this->assertEquals("Starting MySQL server on port {$port} ... ok\n", $output->fetch()); + } + + public function testWithCustomBinary(): void + { + $config = ['binary' => '/usr/bin/mysqld', 'shareDir' => '/some/share/dir']; + $options = []; + $output = new BufferedOutput(); + $this->setFunctionReturn('is_executable', function (string $file): bool { + return $file === '/usr/bin/mysqld' ? true : is_executable($file); + }, true); + $this->setFunctionReturn('is_dir', function (string $dir): bool { + return $dir === '/some/share/dir' ? true : is_dir($dir); + }, true); + $this->setClassMock( + MysqlServer::class, + $this->makeEmptyClass(MysqlServer::class, [ + '__construct' => function (...$args) { + $this->assertEquals([ + codecept_output_dir('_mysql_server'), + MysqlServer::PORT_DEFAULT, + 'wordpress', + 'wordpress', + 'wordpress', + '/usr/bin/mysqld', + '/some/share/dir' + ], $args); + }, + 'start' => null + ]) + ); + $controller = new MysqlServerController($config, $options); + $controller->start($output); + } + + public function testThrowsIfCustomBinaryDoesNotExist(): void{ + $config = ['binary' => '/usr/bin/mysqld']; + $options = []; + $this->setFunctionReturn('is_executable', function (string $file): bool { + return $file === '/usr/bin/mysqld' ? false : is_executable($file); + }, true); + + $this->expectException(ExtensionException::class); + $this->expectExceptionMessage('The "binary" configuration option must be an executable file.'); + + $controller = new MysqlServerController($config, $options); + $controller->start(new NullOutput()); + } + + public function testThrowsIfUsingCustomBinaryAndShareDirNotSet(): void + { + $config = ['binary' => '/usr/bin/mysqld']; + $options = []; + $this->setFunctionReturn('is_executable', function (string $file): bool { + return $file === '/usr/bin/mysqld' ? true : is_executable($file); + }, true); + + $this->expectException(ExtensionException::class); + $this->expectExceptionMessage('The "shareDir" configuration option must be set when using a custom binary.'); + + $controller = new MysqlServerController($config, $options); + $controller->start(new NullOutput()); + } + + public function testThrowsIfShareDirNotADirectory(): void + { + $config = ['binary' => '/usr/bin/mysqld', 'shareDir' => '/some/share/dir']; + $options = []; + $this->setFunctionReturn('is_executable', function (string $file): bool { + return $file === '/usr/bin/mysqld' ? true : is_executable($file); + }, true); + $this->setFunctionReturn('is_dir', function (string $dir): bool { + return $dir === '/some/share/dir' ? false : is_dir($dir); + }, true); + + $this->expectException(ExtensionException::class); + $this->expectExceptionMessage('The "shareDir" configuration option must be a directory.'); + + $controller = new MysqlServerController($config, $options); + $controller->start(new NullOutput()); + } + + public function tesWithRootUserAndPassword(): void + { + $config = ['user' => 'root', 'password' => 'password']; + $options = []; + $output = new BufferedOutput(); + $this->setClassMock( + MysqlServer::class, + $this->makeEmptyClass(MysqlServer::class, [ + '__construct' => function (...$args) { + $this->assertEquals([ + codecept_output_dir('_mysql_server'), + MysqlServer::PORT_DEFAULT, + 'wordpress', + 'root', + 'password', + null + ], $args); + }, + 'start' => null + ]) + ); + $controller = new MysqlServerController($config, $options); + $controller->start(new NullOutput()); + } + + public function testWithRootUserAndEmptyPassword(): void + { + $config = ['user' => 'root', 'password' => '']; + $options = []; + $output = new BufferedOutput(); + $this->setClassMock( + MysqlServer::class, + $this->makeEmptyClass(MysqlServer::class, [ + '__construct' => function (...$args) { + $this->assertEquals([ + codecept_output_dir('_mysql_server'), + MysqlServer::PORT_DEFAULT, + 'wordpress', + 'root', + '', + null, + null + ], $args); + }, + 'start' => null + ]) + ); + $controller = new MysqlServerController($config, $options); + $controller->start(new NullOutput()); + } + + public function testCatchesMysqlServerExceptionDuringStart(): void + { + $config = []; + $options = []; + $output = new BufferedOutput(); + $this->setClassMock( + MysqlServer::class, + $this->makeEmptyClass(MysqlServer::class, [ + 'start' => function () { + throw new \Exception('Something went wrong'); + } + ]) + ); + + $controller = new MysqlServerController($config, $options); + $this->expectException(ExtensionException::class); + $this->expectExceptionMessage('Error while starting MySQL server. Something went wrong'); + $controller->start($output); + } + + public function testWillNotRestartIfAlreadyRunning(): void + { + // Mock the PID file existence. + $pidFile = (new MysqlServerController([], []))->getPidFile(); + $this->setFunctionReturn('is_file', function (string $file) use ($pidFile): bool { + return $file === $pidFile ? true : is_file($file); + }, true); + $this->setClassMock(MysqlServer::class, $this->makeEmptyClass(MysqlServer::class, [ + '__construct' => function () { + throw new AssertionFailedError( + 'The MysqlServer constructor should not be called.' + ); + }, + ])); + + $controller = new MysqlServerController([], []); + $controller->start(new NullOutput); + } + + public function testGetPort(): void + { + $controller = new MysqlServerController([ + 'port' => 12345, + ], []); + + $this->assertEquals(12345, $controller->getPort()); + } + + public function testStopRunningMysqlServer(): void + { + $config = []; + $options = []; + $output = new BufferedOutput(); + $this->setClassMock( + MysqlServer::class, + $this->makeEmptyClass(MysqlServer::class, [ + 'start' => null + ]) + ); + $pidFile = (new MysqlServerController([], []))->getPidFile(); + $this->setFunctionReturn('file_get_contents', function (string $file) use ($pidFile): string|false { + if ($file === $pidFile) { + return '12345'; + } + return file_get_contents($file); + }, true); + $this->setFunctionReturn('exec', function (string $command): string|false { + if ($command !== 'kill 12345 2>&1 > /dev/null') { + throw new AssertionFailedError('Unexpected exec command call: ' . $command); + } + return ''; + }, true); + $this->setFunctionReturn('unlink', function (string $file) use ($pidFile): bool { + if ($file === $pidFile) { + return true; + } + return unlink($file); + }, true); + + $controller = new MysqlServerController($config, $options); + $controller->start($output); + $controller->stop($output); + $port = MysqlServer::PORT_DEFAULT; + $this->assertEquals( + "Starting MySQL server on port {$port} ... ok\nStopping MySQL server with PID 12345 ... ok\n", + $output->fetch() + ); + } + + public function testStopWhenPidFileDoesNotExist(): void + { + $config = []; + $options = []; + $output = new BufferedOutput(); + $this->setClassMock( + MysqlServer::class, + $this->makeEmptyClass(MysqlServer::class, [ + 'start' => null + ]) + ); + $pidFile = (new MysqlServerController([], []))->getPidFile(); + $this->setFunctionReturn('file_get_contents', function (string $file) use ($pidFile): string|false { + if ($file === $pidFile) { + return false; + } + return file_get_contents($file); + }, true); + $this->setFunctionReturn('exec', function (string $command): string|false { + throw new AssertionFailedError('Unexpected exec command call: ' . $command); + }, true); + $this->setFunctionReturn('unlink', function (string $file) use ($pidFile): bool { + throw new AssertionFailedError('Unexpected unlink call for file: ' . $file); + }, true); + + $controller = new MysqlServerController($config, $options); + $controller->start($output); + $controller->stop($output); + $port = MysqlServer::PORT_DEFAULT; + $this->assertEquals( + "Starting MySQL server on port {$port} ... ok\nMySQL server not running.\n", + $output->fetch() + ); + } + + public function testStopThrowsIfPidFileCannotBeUnlinked(): void + { + $config = []; + $options = []; + $output = new BufferedOutput(); + $this->setClassMock( + MysqlServer::class, + $this->makeEmptyClass(MysqlServer::class, [ + 'start' => null + ]) + ); + $pidFile = (new MysqlServerController([], []))->getPidFile(); + $this->setFunctionReturn('file_get_contents', function (string $file) use ($pidFile): string|false { + if ($file === $pidFile) { + return '12345'; + } + return file_get_contents($file); + }, true); + $this->setFunctionReturn('exec', function (string $command): string|false { + return ''; + }, true); + $this->setFunctionReturn('unlink', function (string $file) use ($pidFile): bool { + return false; + }, true); + + $controller = new MysqlServerController($config, $options); + $controller->start($output); + $this->expectException(ExtensionException::class); + $this->expectExceptionMessage("Could not delete PID file '$pidFile'."); + $controller->stop($output); + } + + public function testPrettyName(): void + { + $controller = new MysqlServerController([], []); + $this->assertEquals('MySQL Community Server', $controller->getPrettyName()); + } + + public function testGetInfoWithDefaults(): void + { + $controller = new MysqlServerController([], []); + $pidFile = $controller->getPidFile(); + $this->setFunctionReturn('is_file', function (string $file) use ($pidFile): bool { + return $file === $pidFile ? false : is_file($file); + }, true); + + $port = MysqlServer::PORT_DEFAULT; + $this->assertEquals([ + 'running' => 'no', + 'pidFile' => FS::relativePath(codecept_root_dir(), $controller->getPidFile()), + 'host' => '127.0.0.1', + 'port' => $port, + 'user' => 'wordpress', + 'password' => 'wordpress', + 'root user' => 'root', + 'root password' => '', + ], $controller->getInfo()); + + $this->setFunctionReturn('is_file', function (string $file) use ($pidFile): bool { + return $file === $pidFile ? true : is_file($file); + }, true); + + $this->assertEquals([ + 'running' => 'yes', + 'pidFile' => FS::relativePath(codecept_root_dir(), $controller->getPidFile()), + 'host' => '127.0.0.1', + 'port' => $port, + 'user' => 'wordpress', + 'password' => 'wordpress', + 'root user' => 'root', + 'root password' => '', + 'mysql command' => "mysql -h 127.0.0.1 -P {$port} -u wordpress -p 'wordpress'", + 'mysql root command' => "mysql -h 127.0.0.1 -P {$port} -u root" + ], $controller->getInfo()); + } + + public function testGetInfoWithCustomConfig(): void + { + $controller = new MysqlServerController([ + 'port' => 12345, + 'database' => 'test', + 'user' => 'luca', + 'password' => 'secret', + ], []); + $pidFile = $controller->getPidFile(); + $this->setFunctionReturn('is_file', function (string $file) use ($pidFile): bool { + return $file === $pidFile ? false : is_file($file); + }, true); + + $port = 12345; + $this->assertEquals([ + 'running' => 'no', + 'pidFile' => FS::relativePath(codecept_root_dir(), $controller->getPidFile()), + 'host' => '127.0.0.1', + 'port' => $port, + 'user' => 'luca', + 'password' => 'secret', + 'root user' => 'root', + 'root password' => '', + ], $controller->getInfo()); + + $this->setFunctionReturn('is_file', function (string $file) use ($pidFile): bool { + return $file === $pidFile ? true : is_file($file); + }, true); + $this->assertEquals([ + 'running' => 'yes', + 'pidFile' => FS::relativePath(codecept_root_dir(), $controller->getPidFile()), + 'host' => '127.0.0.1', + 'port' => $port, + 'user' => 'luca', + 'password' => 'secret', + 'root user' => 'root', + 'root password' => '', + 'mysql command' => "mysql -h 127.0.0.1 -P {$port} -u luca -p 'secret'", + 'mysql root command' => "mysql -h 127.0.0.1 -P {$port} -u root" + ], $controller->getInfo()); + } + + public function testGetInfoUsingRootUser(): void + { + $controller = new MysqlServerController([ + 'port' => 12345, + 'database' => 'test', + 'user' => 'root', + 'password' => 'secret', + ], []); + $pidFile = $controller->getPidFile(); + $this->setFunctionReturn('is_file', function (string $file) use ($pidFile): bool { + return $file === $pidFile ? false : is_file($file); + }, true); + + $port = 12345; + $this->assertEquals([ + 'running' => 'no', + 'pidFile' => FS::relativePath(codecept_root_dir(), $controller->getPidFile()), + 'host' => '127.0.0.1', + 'port' => $port, + 'user' => 'root', + 'password' => 'secret', + 'root user' => 'root', + 'root password' => 'secret', + ], $controller->getInfo()); + + $this->setFunctionReturn('is_file', function (string $file) use ($pidFile): bool { + return $file === $pidFile ? true : is_file($file); + }, true); + $this->assertEquals([ + 'running' => 'yes', + 'pidFile' => FS::relativePath(codecept_root_dir(), $controller->getPidFile()), + 'host' => '127.0.0.1', + 'port' => $port, + 'user' => 'root', + 'password' => 'secret', + 'root user' => 'root', + 'root password' => 'secret', + 'mysql command' => "mysql -h 127.0.0.1 -P {$port} -u root -p 'secret'", + 'mysql root command' => "mysql -h 127.0.0.1 -P {$port} -u root -p 'secret'" + ], $controller->getInfo()); + } +} diff --git a/tests/unit/lucatume/WPBrowser/ManagedProcess/MysqlServerTest.php b/tests/unit/lucatume/WPBrowser/ManagedProcess/MysqlServerTest.php new file mode 100644 index 000000000..3fdb721dd --- /dev/null +++ b/tests/unit/lucatume/WPBrowser/ManagedProcess/MysqlServerTest.php @@ -0,0 +1,946 @@ +setFunctionReturn('file_put_contents', function (string $file): void { + throw new AssertionFAiledError('Unexpected file_put_contents call for ' . $file); + }); + + $this->setClassMock(Process::class, $this->makeEmptyClass(Process::class, [ + '__construct' => function (array $command) { + throw new AssertionFAiledError('Unexpected Process::__construct call for ' . print_r($command, true)); + } + ])); + + $this->setClassMock(Download::class, $this->makeEmpty(Download::class, [ + 'fileFromUrl' => function (string $url, string $file): void { + throw new AssertionFAiledError("Unexpected Download::fileFromUrl call for URL $url and file $file"); + } + ])); + + $server = (new MysqlServer()); + $pidFile = $server->getPidFile(); + $this->setFunctionReturn('is_file', function (string $file) use ($pidFile): bool { + return $file === $pidFile ? false : is_file($file); + }, true); + + $directory = $server->getDirectory(); + $this->unsetMkdirFunctionReturn = $this->setFunctionReturn( + 'mkdir', + function (string $dir, ...$rest) use ($directory): bool { + if ($dir === $directory) { + return mkdir($dir, ...$rest); + } + + throw new AssertionFailedError('Unexpected mkdir call for directory ' . $dir); + }, + true + ); + } + + public function osAndArchDataProvider(): array + { + return [ + 'windows x86_64' => [ + MachineInformation::OS_WINDOWS, + MachineInformation::ARCH_X86_64, + FS::cacheDir() . '/mysql-server/mysql-8.4.2-winx64', + FS::cacheDir() . '/mysql-server/mysql-8.4.2-winx64/bin/mysqld.exe', + ], + 'linux x86_64' => [ + MachineInformation::OS_LINUX, + MachineInformation::ARCH_X86_64, + FS::cacheDir() . '/mysql-server/mysql-8.4.2-linux-glibc2.17-x86_64-minimal', + FS::cacheDir() . '/mysql-server/mysql-8.4.2-linux-glibc2.17-x86_64-minimal/bin/mysqld', + ], + 'linux arm64' => [ + MachineInformation::OS_LINUX, + MachineInformation::ARCH_ARM64, + FS::cacheDir() . '/mysql-server/mysql-8.4.2-linux-glibc2.17-aarch64-minimal', + FS::cacheDir() . '/mysql-server/mysql-8.4.2-linux-glibc2.17-aarch64-minimal/bin/mysqld', + ], + 'darwin x86_64' => [ + MachineInformation::OS_DARWIN, + MachineInformation::ARCH_X86_64, + FS::cacheDir() . '/mysql-server/mysql-8.4.2-macos14-x86_64', + FS::cacheDir() . '/mysql-server/mysql-8.4.2-macos14-x86_64/bin/mysqld', + ], + 'darwin arm64' => [ + MachineInformation::OS_DARWIN, + MachineInformation::ARCH_ARM64, + FS::cacheDir() . '/mysql-server/mysql-8.4.2-macos14-arm64', + FS::cacheDir() . '/mysql-server/mysql-8.4.2-macos14-arm64/bin/mysqld', + ] + ]; + } + + /** + * @dataProvider osAndArchDataProvider + */ + public + function testConstructorWithDefaults( + string $os, + string $arch, + string $expectedExtractedPath, + string $expectedBinaryPath + ): void { + $mysqlServer = new MysqlServer(); + $machineInformation = new MachineInformation($os, $arch); + $mysqlServer->setMachineInformation($machineInformation); + $directory = FS::cacheDir() . '/mysql-server'; + $notNormalizedDirectory = $machineInformation->isWindows() ? + str_replace('/', '\\', $directory) + : $directory; + $this->assertEquals($notNormalizedDirectory, $mysqlServer->getDirectory()); + $this->assertEquals($directory, $mysqlServer->getDirectory(true)); + $this->assertEquals(MysqlServer::PORT_DEFAULT, $mysqlServer->getPort()); + $this->assertEquals('wordpress', $mysqlServer->getDatabase()); + $this->assertEquals('wordpress', $mysqlServer->getUser()); + $this->assertEquals('wordpress', $mysqlServer->getPassword()); + $this->assertEquals('', $mysqlServer->getRootPassword()); + $notNormalizedBinaryPath = $machineInformation->isWindows() ? + str_replace('/', '\\', $expectedBinaryPath) + : $expectedBinaryPath; + $this->assertEquals($notNormalizedBinaryPath, $mysqlServer->getBinaryPath()); + $this->assertEquals($expectedBinaryPath, $mysqlServer->getBinaryPath(true)); + $pidFilePath = codecept_output_dir(MysqlServer::PID_FILE_NAME); + $notNormalizedPidFilePath = $machineInformation->isWindows() ? + str_replace('/', '\\', $pidFilePath) + : $pidFilePath; + $this->assertEquals($notNormalizedPidFilePath, $mysqlServer->getPidFilePath()); + $this->assertEquals($pidFilePath, $mysqlServer->getPidFilePath(true)); + $dataDir = FS::cacheDir() . '/mysql-server/data'; + $notNormalizedDataDir = $machineInformation->isWindows() ? + str_replace('/', '\\', $dataDir) + : $dataDir; + $this->assertEquals($notNormalizedDataDir, $mysqlServer->getDataDir()); + $this->assertEquals($dataDir, $mysqlServer->getDataDir(true)); + $notNormalizedExtractedPath = $machineInformation->isWindows() ? + str_replace('/', '\\', $expectedExtractedPath) + : $expectedExtractedPath; + $this->assertEquals($notNormalizedExtractedPath, $mysqlServer->getExtractedPath()); + $this->assertEquals($expectedExtractedPath, $mysqlServer->getExtractedPath(true)); + $shareDir = $expectedExtractedPath . '/share'; + $notNormalizedShareDir = $machineInformation->isWindows() ? + str_replace('/', '\\', $shareDir) + : $shareDir; + $this->assertEquals($notNormalizedShareDir, $mysqlServer->getShareDir()); + $this->assertEquals($shareDir, $mysqlServer->getShareDir(true)); + $this->assertFalse($mysqlServer->isUsingCustomBinary()); + $socketPath = $directory . '/mysql.sock'; + $notNormalizedSocketPath = $machineInformation->isWindows() ? + str_replace('/', '\\', $socketPath) + : $socketPath; + $this->assertEquals($notNormalizedSocketPath, $mysqlServer->getSocketPath()); + $this->assertEquals($socketPath, $mysqlServer->getSocketPath(true)); + } + + public function testConstructorCustomValues(): void + { + $mysqlServer = new MysqlServer(__DIR__, 2389, 'test', 'luca', 'secret'); + $this->assertEquals(2389, $mysqlServer->getPort()); + $this->assertEquals('test', $mysqlServer->getDatabase()); + $this->assertEquals('luca', $mysqlServer->getUser()); + $this->assertEquals('secret', $mysqlServer->getPassword()); + $this->assertEquals('', $mysqlServer->getRootPassword()); + } + + public function testConstructorWithRootUser(): void + { + $mysqlServer = new MysqlServer(__DIR__, 2389, 'test', 'root', 'secret'); + $this->assertEquals(2389, $mysqlServer->getPort()); + $this->assertEquals('test', $mysqlServer->getDatabase()); + $this->assertEquals('root', $mysqlServer->getUser()); + $this->assertEquals('secret', $mysqlServer->getPassword()); + $this->assertEquals('secret', $mysqlServer->getRootPassword()); + } + + public function testConstructorCreatesDirectoryIfNotExists(): void + { + ($this->unsetMkdirFunctionReturn)(); + $dir = FS::tmpDir('mysql-server_'); + $mysqlServer = new MysqlServer($dir); + $this->assertDirectoryExists($dir); + } + + /** + * @dataProvider osAndArchDataProvider + */ + public function testConstructorWithCustomBinary(string $os, string $arch): void + { + $this->setFunctionReturn('is_executable', function (string $file): bool { + return $file === '/usr/bin/mysqld' ? true : is_executable($file); + }, true); + $this->setFunctionReturn('is_dir', function (string $dir): bool { + return $dir === '/some/share/dir' ? true : is_dir($dir); + }, true); + $mysqlServer = new MysqlServer( + __DIR__, + 2389, + 'test', + 'root', + 'secret', + '/usr/bin/mysqld', + '/some/share/dir' + ); + $machineInformation = new MachineInformation($os, $arch); + $mysqlServer->setMachineInformation($machineInformation); + $directory = __DIR__; + $notNormalizedDirectory = $machineInformation->isWindows() ? + str_replace('/', '\\', $directory) + : $directory; + $this->assertEquals($notNormalizedDirectory, $mysqlServer->getDirectory()); + $this->assertEquals($directory, $mysqlServer->getDirectory(true)); + $this->assertEquals(2389, $mysqlServer->getPort()); + $this->assertEquals('test', $mysqlServer->getDatabase()); + $this->assertEquals('root', $mysqlServer->getUser()); + $this->assertEquals('secret', $mysqlServer->getPassword()); + $this->assertEquals('secret', $mysqlServer->getRootPassword()); + $this->assertTrue($mysqlServer->isUsingCustomBinary()); + $notNormalizedBinaryPath = $machineInformation->isWindows() ? '\\usr\\bin\\mysqld' : '/usr/bin/mysqld'; + $this->assertEquals($notNormalizedBinaryPath, $mysqlServer->getBinaryPath()); + $this->assertEquals('/usr/bin/mysqld', $mysqlServer->getBinaryPath(true)); + $dataDir = __DIR__ . '/data'; + $notNormalizedDataDir = $machineInformation->isWindows() ? + str_replace('/', '\\', $dataDir) + : $dataDir; + $this->assertEquals($notNormalizedDataDir, $mysqlServer->getDataDir()); + $this->assertEquals($dataDir, $mysqlServer->getDataDir(true)); + } + + public function testGetExtractedPathThrowsForCustomBinary(): void + { + $this->setFunctionReturn('is_executable', function (string $file): bool { + return $file === '/usr/bin/mysqld' ? true : is_executable($file); + }, true); + $this->setFunctionReturn('is_dir', function (string $dir): bool { + return $dir === '/some/share/dir' ? true : is_dir($dir); + }, true); + $mysqlServer = new MysqlServer( + __DIR__, + 2389, + 'test', + 'root', + 'secret', + '/usr/bin/mysqld', + '/some/share/dir' + ); + $this->expectException(RuntimeException::class); + $this->expectExceptionCode(MysqlServer::ERR_CUSTOM_BINARY_EXTRACTED_PATH); + $mysqlServer->getExtractedPath(); + } + + public function testConstructorThrowsIfShareDirNotSetForCustomBinary(): void + { + $this->setFunctionReturn('is_executable', function (string $file): bool { + return $file === '/usr/bin/mysqld' ? true : is_executable($file); + }, true); + $this->setFunctionReturn('is_dir', function (string $dir): bool { + return $dir === '/some/share/dir' ? true : is_dir($dir); + }, true); + $this->expectException(RuntimeException::class); + $this->expectExceptionCode(MysqlServer::ERR_CUSTOM_BINARY_SHARE_DIR_PATH); + $mysqlServer = new MysqlServer( + __DIR__, + 2389, + 'test', + 'root', + 'secret', + '/usr/bin/mysqld' + ); + } + + public function testGetShareDireForCustomBinaryAndSetCustomShareDir(): void + { + $this->setFunctionReturn('is_executable', function (string $file): bool { + return $file === '/usr/bin/mysqld' ? true : is_executable($file); + }, true); + $this->setFunctionReturn('is_dir', function (string $dir): bool { + return $dir === '/some/share/dir' ? true : is_dir($dir); + }, true); + $mysqlServer = new MysqlServer( + __DIR__, + 2389, + 'test', + 'root', + 'secret', + '/usr/bin/mysqld', + '/some/share/dir' + ); + $shareDir = $mysqlServer->getShareDir(); + $this->assertEquals('/some/share/dir', $shareDir); + } + + public function testConstructorThrowsIfDirectoryCannotBeCreated(): void + { + $this->setFunctionReturn('mkdir', function (string $dir, ...$rest): bool { + return false; + }, true); + $this->expectException(RuntimeException::class); + $this->expectExceptionCode(MysqlServer::ERR_MYSQL_DIR_NOT_CREATED); + new MysqlServer('/my-data-dir'); + } + + public function startWithCustomParametersDataProvider(): Generator + { + foreach ($this->osAndArchDataProvider() as [$os, $arch]) { + yield "{$os}_{$arch}_default_parameters" => [ + $os, + $arch, + [], + [ + 'CREATE DATABASE IF NOT EXISTS `wordpress`', + "CREATE USER IF NOT EXISTS 'wordpress'@'%' IDENTIFIED BY 'wordpress'", + "GRANT ALL PRIVILEGES ON `wordpress`.* TO 'wordpress'@'%'", + 'FLUSH PRIVILEGES', + ] + ]; + + yield "{$os}_{$arch}_custom_parameters" => [ + $os, + $arch, + [ + 12345, + 'someDatabase', + 'someUser', + 'password' + ], + [ + 'CREATE DATABASE IF NOT EXISTS `someDatabase`', + "CREATE USER IF NOT EXISTS 'someUser'@'%' IDENTIFIED BY 'password'", + "GRANT ALL PRIVILEGES ON `someDatabase`.* TO 'someUser'@'%'", + 'FLUSH PRIVILEGES', + ] + ]; + } + } + + /** + * @dataProvider startWithCustomParametersDataProvider + */ + public function testStartAndStop(string $os, string $arch, array $params, array $expectedQueries): void + { + ($this->unsetMkdirFunctionReturn)(); + $dir = FS::tmpDir('mysql-server_'); + $mysqlServer = new MysqlServer($dir, ...$params); + $machineInformation = new MachineInformation($os, $arch); + $mysqlServer->setMachineInformation($machineInformation); + + // Mock the download of the archive. + $this->setMethodReturn( + Download::class, + 'fileFromUrl', + function (string $url, string $file) use ($mysqlServer): void { + Assert::assertEquals($mysqlServer->getArchiveUrl(), $url); + Assert::assertEquals($mysqlServer->getArchivePath(true), $file); + $archiveBasename = basename($mysqlServer->getArchiveUrl()); + copy(codecept_data_dir('mysql-server/mock-archives/' . $archiveBasename), $file); + }, + true + ); + + // Mock the extraction of the archive on Windows. + if ($machineInformation->isWindows()) { + $this->setClassMock( + PharData::class, + $this->makeEmptyClass(PharData::class, [ + 'extractTo' => function (string $directory, ?array $files = null, bool $overwrite = false) use ( + $mysqlServer + ): bool { + Assert::assertEquals($mysqlServer->getDirectory(true), $directory); + Assert::assertNull($files); + Assert::assertTrue($overwrite); + $extractedPath = $mysqlServer->getExtractedPath(true); + mkdir($extractedPath . '/share', 0777, true); + mkdir($extractedPath . '/bin', 0777, true); + touch($extractedPath . '/bin/mysqld.exe'); + chmod($extractedPath . '/bin/mysqld.exe', 0777); + return true; + }, + ]) + ); + } + + // Mock the processes to initialize and start the server. + $mockProcessStep = $machineInformation->isWindows() ? 'init' : 'extract'; + $this->setClassMock( + Process::class, + $this->makeEmptyClass( + Process::class, + [ + '__construct' => function (array $command) use (&$mockProcessStep, $mysqlServer) { + $archivePath = $mysqlServer->getArchivePath(); + $extension = pathinfo($archivePath, PATHINFO_EXTENSION); + $tarFlags = $extension === 'xz' ? '-xf' : '-xzf'; + if ($mockProcessStep === 'extract') { + Assert::assertEquals([ + 'tar', + $tarFlags, + $mysqlServer->getArchivePath(), + '-C', + $mysqlServer->getDirectory(), + ], $command); + $mockProcessStep = 'init'; + $extractedPath = $mysqlServer->getExtractedPath(true); + mkdir($extractedPath . '/share', 0777, true); + mkdir($extractedPath . '/bin', 0777, true); + touch($extractedPath . '/bin/mysqld'); + chmod($extractedPath . '/bin/mysqld', 0777); + return; + } + + if ($mockProcessStep === 'init') { + Assert::assertEquals([ + $mysqlServer->getBinaryPath(), + '--no-defaults', + '--initialize-insecure', + '--innodb-flush-method=nosync', + '--datadir=' . $mysqlServer->getDataDir(), + '--pid-file=' . $mysqlServer->getPidFilePath(), + ], $command); + $mockProcessStep = 'start'; + return; + } + + if ($mockProcessStep === 'start') { + Assert::assertEquals([ + $mysqlServer->getBinaryPath(), + '--datadir=' . $mysqlServer->getDataDir(), + '--skip-mysqlx', + '--default-time-zone=+00:00', + '--innodb-flush-method=nosync', + '--innodb-flush-log-at-trx-commit=0', + '--innodb-doublewrite=0', + '--bind-address=localhost', + '--lc-messages-dir=' . $mysqlServer->getShareDir(), + '--socket=' . $mysqlServer->getSocketPath(), + '--port=' . $mysqlServer->getPort(), + '--pid-file=' . $mysqlServer->getPidFilePath() + ], $command); + $mockProcessStep = 'started'; + return; + } + + throw new AssertionFailedError( + 'Unexpected Process::__construct call for ' . print_r($command, true) + ); + }, + 'mustRun' => '__itself', + 'isRunning' => function () use (&$mockProcessStep): bool { + return $mockProcessStep === 'started'; + }, + 'getPid' => 2389, + 'stop' => function () use (&$mockProcessStep): int { + Assert::assertTrue(in_array($mockProcessStep, ['started', 'stopped'], true)); + $mockProcessStep = 'stopped'; + return 0; + } + ] + ) + ); + + // Mock the PDO connection. + $queries = []; + $this->setClassMock(PDO::class, $this->makeEmptyClass(PDO::class, [ + '__construct' => function ( + string $dsn, + string $user, + string $password + ) use ($mysqlServer): void { + Assert::assertEquals('mysql:host=127.0.0.1;port=' . $mysqlServer->getPort(), $dsn); + Assert::assertEquals('root', $user); + Assert::assertEquals($mysqlServer->getRootPassword(), $password); + }, + 'exec' => function (string $query) use (&$queries): int|false { + $queries[] = $query; + return 1; + } + ])); + + // Mock the PID file write. + $pidFile = MysqlServer::getPidFile(); + $this->setFunctionReturn('file_put_contents', function (string $file, $pid) use ($pidFile): bool { + Assert::assertEquals($pidFile, $file); + Assert::assertEquals(2389, $pid); + return true; + }, true); + $this->setFunctionReturn('file_get_contents', function (string $file) use ($pidFile): bool { + Assert::assertEquals($pidFile, $file); + return 2389; + }, true); + + $mysqlServer->start(); + + $this->assertEquals($expectedQueries, $queries); + + $mysqlServer->stop(); + } + + /** + * @dataProvider osAndArchDataProvider + */ + public function testStartWithRootUser(string $os, string $arch): void + { + ($this->unsetMkdirFunctionReturn)(); + $dir = FS::tmpDir('mysql-server_'); + $mysqlServer = new MysqlServer($dir, 12345, 'someDatabase', 'root', 'secret'); + $machineInformation = new MachineInformation($os, $arch); + $mysqlServer->setMachineInformation($machineInformation); + + // Mock the download of the archive. + $this->setMethodReturn( + Download::class, + 'fileFromUrl', + function (string $url, string $file) use ($mysqlServer): void { + Assert::assertEquals($mysqlServer->getArchiveUrl(), $url); + Assert::assertEquals($mysqlServer->getArchivePath(true), $file); + $archiveBasename = basename($mysqlServer->getArchiveUrl()); + copy(codecept_data_dir('mysql-server/mock-archives/' . $archiveBasename), $file); + }, + true + ); + + // Mock the extraction of the archive on Windows. + if ($machineInformation->isWindows()) { + $this->setClassMock( + PharData::class, + $this->makeEmptyClass(PharData::class, [ + 'extractTo' => function (string $directory, ?array $files = null, bool $overwrite = false) use ( + $mysqlServer + ): bool { + Assert::assertEquals($mysqlServer->getDirectory(true), $directory); + Assert::assertNull($files); + Assert::assertTrue($overwrite); + $extractedPath = $mysqlServer->getExtractedPath(true); + mkdir($extractedPath . '/share', 0777, true); + mkdir($extractedPath . '/bin', 0777, true); + touch($extractedPath . '/bin/mysqld.exe'); + chmod($extractedPath . '/bin/mysqld.exe', 0777); + return true; + }, + ]) + ); + } + + // Mock the processes to initialize and start the server. + $mockProcessStep = $machineInformation->isWindows() ? 'init' : 'extract'; + $this->setClassMock( + Process::class, + $this->makeEmptyClass( + Process::class, + [ + '__construct' => function (array $command) use (&$mockProcessStep, $mysqlServer) { + $archivePath = $mysqlServer->getArchivePath(); + $extension = pathinfo($archivePath, PATHINFO_EXTENSION); + $tarFlags = $extension === 'xz' ? '-xf' : '-xzf'; + if ($mockProcessStep === 'extract') { + Assert::assertEquals([ + 'tar', + $tarFlags, + $mysqlServer->getArchivePath(), + '-C', + $mysqlServer->getDirectory(), + ], $command); + $mockProcessStep = 'init'; + $extractedPath = $mysqlServer->getExtractedPath(true); + mkdir($extractedPath . '/share', 0777, true); + mkdir($extractedPath . '/bin', 0777, true); + touch($extractedPath . '/bin/mysqld'); + chmod($extractedPath . '/bin/mysqld', 0777); + return; + } + + if ($mockProcessStep === 'init') { + Assert::assertEquals([ + $mysqlServer->getBinaryPath(), + '--no-defaults', + '--initialize-insecure', + '--innodb-flush-method=nosync', + '--datadir=' . $mysqlServer->getDataDir(), + '--pid-file=' . $mysqlServer->getPidFilePath(), + ], $command); + $mockProcessStep = 'start'; + return; + } + + if ($mockProcessStep === 'start') { + Assert::assertEquals([ + $mysqlServer->getBinaryPath(), + '--datadir=' . $mysqlServer->getDataDir(), + '--skip-mysqlx', + '--default-time-zone=+00:00', + '--innodb-flush-method=nosync', + '--innodb-flush-log-at-trx-commit=0', + '--innodb-doublewrite=0', + '--bind-address=localhost', + '--lc-messages-dir=' . $mysqlServer->getShareDir(), + '--socket=' . $mysqlServer->getSocketPath(), + '--port=' . $mysqlServer->getPort(), + '--pid-file=' . $mysqlServer->getPidFilePath() + ], $command); + $mockProcessStep = 'started'; + return; + } + + throw new AssertionFailedError( + 'Unexpected Process::__construct call for ' . print_r($command, true) + ); + }, + 'mustRun' => '__itself', + 'isRunning' => function () use (&$mockProcessStep): bool { + return $mockProcessStep === 'started'; + }, + 'getPid' => 2389 + ] + ) + ); + + // Mock the PDO connection. + $queries = []; + $calls = 0; + $this->setClassMock(PDO::class, $this->makeEmptyClass(PDO::class, [ + '__construct' => function ( + string $dsn, + string $user, + string $password + ) use ($mysqlServer, &$calls): void { + if ($calls === 0) { + // The first call with the not-yet set root password will fail. + Assert::assertEquals('mysql:host=127.0.0.1;port=' . $mysqlServer->getPort(), $dsn); + Assert::assertEquals('root', $user); + Assert::assertEquals($mysqlServer->getRootPassword(), $password); + ++$calls; + throw new \PDOException('Error'); + } + + if ($calls === 1) { + // Second call is done with the empty root password. + Assert::assertEquals('mysql:host=127.0.0.1;port=' . $mysqlServer->getPort(), $dsn); + Assert::assertEquals('root', $user); + Assert::assertEquals('', $password); + ++$calls; + } else { + // Further calls should be done with the now set correct root password. + Assert::assertEquals('mysql:host=127.0.0.1;port=' . $mysqlServer->getPort(), $dsn); + Assert::assertEquals('root', $user); + Assert::assertEquals($mysqlServer->getRootPassword(), $password); + ++$calls; + } + }, + 'exec' => function (string $query) use (&$queries): int|false { + $queries[] = $query; + return 1; + } + ])); + + // Mock the PID file write. + $pidFile = MysqlServer::getPidFile(); + $this->setFunctionReturn('file_put_contents', function (string $file, $pid) use ($pidFile): bool { + Assert::assertEquals($pidFile, $file); + Assert::assertEquals(2389, $pid); + return true; + }, true); + $this->setFunctionReturn('file_get_contents', function (string $file) use ($pidFile): bool { + Assert::assertEquals($pidFile, $file); + return 2389; + }, true); + + $mysqlServer->start(); + + $this->assertEquals( + [ + "ALTER USER 'root'@'localhost' IDENTIFIED BY 'secret'", + 'CREATE DATABASE IF NOT EXISTS `someDatabase`', + 'FLUSH PRIVILEGES', + ], + $queries + ); + } + + /** + * @dataProvider osAndArchDataProvider + */ + public function testStartServerWithCustomBinary(string $os, string $arch): void + { + ($this->unsetMkdirFunctionReturn)(); + $dir = FS::tmpDir('mysql-server_'); + $machineInformation = new MachineInformation($os, $arch); + + // The custom binary exists and is executable. + if ($machineInformation->isWindows()) { + $this->setFunctionReturn('is_executable', function (string $file): bool { + return $file === 'C:/usr/bin/mysqld.exe' ? true : is_executable($file); + }, true); + } else { + $this->setFunctionReturn('is_executable', function (string $file): bool { + return $file === '/usr/bin/mysqld' ? true : is_executable($file); + }, true); + } + + // The custom share directory exists. + if ($machineInformation->isWindows()) { + $this->setFunctionReturn('is_dir', function (string $dir): bool { + return $dir === 'C:\\some\\share\\dir' ? true : is_dir($dir); + }, true); + } else { + $this->setFunctionReturn('is_dir', function (string $dir): bool { + return $dir === '/some/share/dir' ? true : is_dir($dir); + }, true); + } + + $mysqlServer = new MysqlServer( + $dir, + 12345, + 'someDatabase', + 'someUser', + 'password', + $machineInformation->isWindows() ? 'C:\\usr\\bin\\mysqld.exe' : '/usr/bin/mysqld', + $machineInformation->isWindows() ? 'C:\\some\\share\\dir' : '/some/share/dir' + ); + $mysqlServer->setMachineInformation($machineInformation); + + // Mock the download of the archive. + $this->setMethodReturn( + Download::class, + 'fileFromUrl', + function (string $url, string $file) use ($mysqlServer): void { + throw new AssertionFailedError('No file should be downloaded.'); + }, + true + ); + + // Mock the extraction of the archive on Windows. + if ($machineInformation->isWindows()) { + $this->setClassMock( + PharData::class, + $this->makeEmptyClass(PharData::class, [ + 'extractTo' => fn() => throw new AssertionFailedError( + 'No extraction should be performed on Windows.' + ) + ]) + ); + } + + // Mock the processes to initialize and start the server. + $mockProcessStep = 'init'; + $this->setClassMock( + Process::class, + $this->makeEmptyClass( + Process::class, + [ + '__construct' => function (array $command) use (&$mockProcessStep, $mysqlServer) { + if ($mockProcessStep === 'init') { + Assert::assertEquals([ + $mysqlServer->getBinaryPath(), + '--no-defaults', + '--initialize-insecure', + '--innodb-flush-method=nosync', + '--datadir=' . $mysqlServer->getDataDir(), + '--pid-file=' . $mysqlServer->getPidFilePath(), + ], $command); + $mockProcessStep = 'start'; + return; + } + + if ($mockProcessStep === 'start') { + Assert::assertEquals([ + $mysqlServer->getBinaryPath(), + '--datadir=' . $mysqlServer->getDataDir(), + '--skip-mysqlx', + '--default-time-zone=+00:00', + '--innodb-flush-method=nosync', + '--innodb-flush-log-at-trx-commit=0', + '--innodb-doublewrite=0', + '--bind-address=localhost', + '--lc-messages-dir=' . $mysqlServer->getShareDir(), + '--socket=' . $mysqlServer->getSocketPath(), + '--port=' . $mysqlServer->getPort(), + '--pid-file=' . $mysqlServer->getPidFilePath() + ], $command); + $mockProcessStep = 'started'; + return; + } + + throw new AssertionFailedError( + 'Unexpected Process::__construct call for ' . print_r($command, true) + ); + }, + 'mustRun' => '__itself', + 'isRunning' => function () use (&$mockProcessStep): bool { + return $mockProcessStep === 'started'; + }, + 'getPid' => 2389 + ] + ) + ); + + // Mock the PDO connection. + $queries = []; + $this->setClassMock(PDO::class, $this->makeEmptyClass(PDO::class, [ + '__construct' => function ( + string $dsn, + string $user, + string $password + ) use ($mysqlServer): void { + Assert::assertEquals('mysql:host=127.0.0.1;port=' . $mysqlServer->getPort(), $dsn); + Assert::assertEquals('root', $user); + Assert::assertEquals($mysqlServer->getRootPassword(), $password); + }, + 'exec' => function (string $query) use (&$queries): int|false { + $queries[] = $query; + return 1; + } + ])); + + // Mock the PID file write. + $pidFile = MysqlServer::getPidFile(); + $this->setFunctionReturn('file_put_contents', function (string $file, $pid) use ($pidFile): bool { + Assert::assertEquals($pidFile, $file); + Assert::assertEquals(2389, $pid); + return true; + }, true); + $this->setFunctionReturn('file_get_contents', function (string $file) use ($pidFile): bool { + Assert::assertEquals($pidFile, $file); + return 2389; + }, true); + + $mysqlServer->start(); + + $this->assertEquals( + [ + 'CREATE DATABASE IF NOT EXISTS `someDatabase`', + "CREATE USER IF NOT EXISTS 'someUser'@'%' IDENTIFIED BY 'password'", + "GRANT ALL PRIVILEGES ON `someDatabase`.* TO 'someUser'@'%'", + 'FLUSH PRIVILEGES', + ], + $queries + ); + } + + /** + * @dataProvider osAndArchDataProvider + */ + public function testStartWhenAlreadyRunning(string $os, string $arch): void + { + $pidFile = MysqlServer::getPidFile(); + + // The PID file exists. + $this->setFunctionReturn('is_file', function (string $file) use ($pidFile): bool { + return $file === $pidFile ? true : is_file($file); + }, true); + + $this->setClassMock(Process::class, $this->makeEmptyClass(Process::class, [ + '__construct' => fn() => throw new AssertionFailedError('No process should be started.'), + ])); + + $this->setClassMock(PDO::class, $this->makeEmptyClass(PDO::class, [ + '__construct' => fn() => throw new AssertionFailedError('No PDO connection should be made.'), + ])); + + $machineInformation = new MachineInformation($os, $arch); + $mysqlServer = new MysqlServer(__DIR__); + $mysqlServer->setMachineInformation($machineInformation); + } + + public function testStopThrowsIfNotRunning(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('MySQL Server not started.'); + + $mysqlServer = new MysqlServer(__DIR__); + $mysqlServer->stop(); + } + + public function testStopThrowsIfPidFileCannotBeUnlinked(): void + { + ($this->unsetMkdirFunctionReturn)(); + $dir = FS::tmpDir('mysql-server_'); + // The custom binary exists and is executable. + $this->setFunctionReturn('is_executable', function (string $file): bool { + return $file === '/usr/bin/mysqld' ? true : is_executable($file); + }, true); + + // The custom share directory exists. + $this->setFunctionReturn('is_dir', function (string $dir): bool { + return $dir === '/some/share/dir' ? true : is_dir($dir); + }, true); + + // Mock the processes to initialize and start the server. + $this->setClassMock( + Process::class, + $this->makeEmptyClass( + Process::class, + [ + 'mustRun' => '__itself', + 'getPid' => 2389, + 'stop' => 0, + 'isRunning' => true, + ] + ) + ); + + $pidFile = MysqlServer::getPidFile(); + + // Mock the PID file write. + $pidFileExists = false; + $this->setFunctionReturn('file_put_contents', function (string $file, $pid) use ($pidFile,&$pidFileExists): bool { + Assert::assertEquals($pidFile, $file); + Assert::assertEquals(2389, $pid); + $pidFileExists = true; + return true; + }, true); + + // The PID file exists. + $this->setFunctionReturn('is_file', function (string $file) use (&$pidFileExists, $pidFile): bool { + return $file === $pidFile ? $pidFileExists : is_file($file); + }, true); + + // The PID file cannot be unlinked. + $unlinked = false; + $this->setFunctionReturn('unlink', function (string $file) use (&$pidFile): bool { + return $file === $pidFile ? false : unlink($file); + }, true); + + // Mock the PDO constructor. + $pdoConstructorCalledWithCorrectArgs = false; + $this->setClassMock(PDO::class, $this->makeEmptyClass(PDO::class, [ + 'exec' => 1 + ])); + + $mysqlServer = new MysqlServer($dir, 12345, 'someDatabase', 'root', 'secret','/usr/bin/mysqld', '/some/share/dir'); + $mysqlServer->start(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage("Could not remove PID file {$pidFile}."); + + $mysqlServer->stop(); + } +} diff --git a/tests/unit/lucatume/WPBrowser/Traits/UopzFunctionsTest.php b/tests/unit/lucatume/WPBrowser/Traits/UopzFunctionsTest.php index 7b1c9e35a..8bc20513d 100644 --- a/tests/unit/lucatume/WPBrowser/Traits/UopzFunctionsTest.php +++ b/tests/unit/lucatume/WPBrowser/Traits/UopzFunctionsTest.php @@ -1282,4 +1282,323 @@ public function should_restore_exit_between_tests(): void { $this->assertEquals(1, ini_get('uopz.exit')); } + + public function testSetFunctionReturnReturnsUnsetClosure(): void + { + $unsetReturn = $this->setFunctionReturn('someTestFunction', 23); + + $this->assertEquals(23, someTestFunction()); + + $unsetReturn(); + + $this->assertEquals('test-test-test', someTestFunction()); + } + + public function testSetInstanceMethodReturnReturnsUnsetClosure(): void + { + $unsetReturn = $this->setMethodReturn(SomeGlobalClassOne::class, 'getValueOne', 23); + + $this->assertEquals(23, (new SomeGlobalClassOne())->getValueOne()); + + $unsetReturn(); + + $this->assertEquals('original-value-one', (new SomeGlobalClassOne())->getValueOne()); + } + + public function testSetStaticMethodReturnReturnsUnsetClosure(): void + { + $unsetReturn = $this->setMethodReturn(SomeGlobalClassOne::class, 'getStaticValueOne', 23); + + $this->assertEquals(23, SomeGlobalClassOne::getStaticValueOne()); + + $unsetReturn(); + + $this->assertEquals('original-static-value-one', SomeGlobalClassOne::getStaticValueOne()); + } + + public function testSetFunctionHookReturnsUnsetClosure(): void + { + $headers = []; + $hook = function (string $header, bool $replace = true, int $response_code = 0) use ( + &$headers + ): void { + $headers[] = [ + 'header' => $header, + 'replace' => $replace, + 'response_code' => $response_code, + ]; + }; + + $headers = []; + $unsetFunctionHook = $this->setFunctionHook('header', $hook); + + header('Location: http://example.com', true, 301); + + $this->assertEquals([ + [ + 'header' => 'Location: http://example.com', + 'replace' => true, + 'response_code' => 301, + ], + ], $headers); + + $unsetFunctionHook(); + + header('X-Test: hello', true); + + $this->assertEquals([ + [ + 'header' => 'Location: http://example.com', + 'replace' => true, + 'response_code' => 301, + ], + ], $headers); + } + + public function testSetInstanceMethodHookReturnsUnsetClosure(): void + { + $gotten = 0; + $unsetMethodHook = $this->setMethodHook(SomeGlobalClassOne::class, 'getValueOne', function () use (&$gotten) { + $gotten++; + }); + $someGlobalClassOne = new SomeGlobalClassOne(); + $someGlobalClassOne->getValueOne(); + $someGlobalClassOne->getValueOne(); + $someGlobalClassOne->getValueOne(); + + $this->assertEquals(3, $gotten); + + $unsetMethodHook(); + + $someGlobalClassOne->getValueOne(); + $someGlobalClassOne->getValueOne(); + $someGlobalClassOne->getValueOne(); + + $this->assertEquals(3, $gotten); + } + + public function testSetStaticMethodHookReturnsUnsetClosure(): void + { + $gotten = 0; + $unsetMethodHook = $this->setMethodHook( + SomeGlobalClassOne::class, + 'getStaticValueOne', + function () use (&$gotten): void { + $gotten++; + } + ); + + SomeGlobalClassOne::getStaticValueOne(); + SomeGlobalClassOne::getStaticValueOne(); + SomeGlobalClassOne::getStaticValueOne(); + + $this->assertEquals(3, $gotten); + + $unsetMethodHook(); + + SomeGlobalClassOne::getStaticValueOne(); + SomeGlobalClassOne::getStaticValueOne(); + SomeGlobalClassOne::getStaticValueOne(); + + $this->assertEquals(3, $gotten); + } + + public function testSetConstantUnsetClosure(): void + { + $unsetConstant = $this->setConstant('EXISTING_CONSTANT', 'hello'); + + $this->assertEquals('hello', EXISTING_CONSTANT); + + $unsetConstant(); + + $this->assertEquals('test-constant', EXISTING_CONSTANT); + } + + public function testSetClassConstantUnsetClosure(): void + { + $unsetClassConstant = $this->setClassConstant(SomeGlobalClassOne::class, 'EXISTING_CONSTANT', 'hello'); + + $this->assertEquals('hello', SomeGlobalClassOne::EXISTING_CONSTANT); + + $unsetClassConstant(); + + $this->assertEquals('test-constant', SomeGlobalClassOne::EXISTING_CONSTANT); + } + + public function testSetClassMockUnsetClosure(): void + { + $mockSomeGlobalClassOne = new class extends SomeGlobalClassOne { + public function getValueOne(): string + { + return 'mocked-value-one'; + } + }; + + $unsetClassMock = $this->setClassMock(SomeGlobalClassOne::class, $mockSomeGlobalClassOne); + + $mockSomeGlobalClassOneInstanceOne = new SomeGlobalClassOne(); + $mockSomeGlobalClassOneInstanceTwo = new SomeGlobalClassOne(); + + $this->assertSame($mockSomeGlobalClassOne, $mockSomeGlobalClassOneInstanceOne); + $this->assertSame($mockSomeGlobalClassOne, $mockSomeGlobalClassOneInstanceTwo); + $this->assertEquals('mocked-value-one', $mockSomeGlobalClassOneInstanceOne->getValueOne()); + $this->assertEquals('mocked-value-one', $mockSomeGlobalClassOneInstanceTwo->getValueOne()); + + $unsetClassMock(); + + $mockSomeGlobalClassOneInstanceOne = new SomeGlobalClassOne(); + $mockSomeGlobalClassOneInstanceTwo = new SomeGlobalClassOne(); + + $this->assertNotSame($mockSomeGlobalClassOne, $mockSomeGlobalClassOneInstanceOne); + $this->assertNotSame($mockSomeGlobalClassOne, $mockSomeGlobalClassOneInstanceTwo); + $this->assertEquals('original-value-one', $mockSomeGlobalClassOneInstanceOne->getValueOne()); + $this->assertEquals('original-value-one', $mockSomeGlobalClassOneInstanceTwo->getValueOne()); + } + + public function testUnsetClassFinalAttributeUnsetClosure(): void + { + $unsetClassFinalAttribute = $this->unsetClassFinalAttribute(SomeGlobalFinalClass::class); + + $globalExtension = new class extends SomeGlobalFinalClass { + public function someMethod(): int + { + return 89; + } + }; + + $this->assertEquals(89, $globalExtension->someMethod()); + + $unsetClassFinalAttribute(); + + $this->assertTrue((new ReflectionClass(SomeGlobalFinalClass::class))->isFinal()); + } + + public function testUnsetMethodFinalAttributeUnsetClosure(): void + { + $unsetMethodFinalAttribute = $this->unsetMethodFinalAttribute( + SomeGlobalClassWithFinalMethods::class, + 'someFinalMethod' + ); + + $globalExtension = new class extends SomeGlobalClassWithFinalMethods { + public function someFinalMethod(): int + { + return 123; + } + }; + + $this->assertEquals(123, $globalExtension->someFinalMethod()); + + $unsetMethodFinalAttribute(); + + $this->assertTrue((new ReflectionMethod(SomeGlobalClassWithFinalMethods::class, 'someFinalMethod'))->isFinal()); + } + + public function testAddClassMethodUnsetClosure(): void + { + $unsetAddClassInstanceMethod = $this->addClassMethod( + SomeGlobalClassWithoutMethods::class, + 'testInstanceMethod', + function (): int { + return $this->number; + } + ); + $unsetAddClassStaticMethod = $this->addClassMethod( + SomeGlobalClassWithoutMethods::class, + 'testStaticMethod', + function (): string { + return self::$name; + }, + true + ); + + $this->assertTrue(method_exists(SomeGlobalClassWithoutMethods::class, 'testInstanceMethod')); + $this->assertTrue(method_exists(SomeGlobalClassWithoutMethods::class, 'testStaticMethod')); + + $someGlobalClassWithoutMethods = new SomeGlobalClassWithoutMethods(); + $someGlobalClassWithoutMethods->testInstanceMethod(); + $someGlobalClassWithoutMethods->testStaticMethod(); + + $unsetAddClassInstanceMethod(); + + $this->assertFalse(method_exists(SomeGlobalClassWithoutMethods::class, 'testInstanceMethod')); + $this->assertTrue(method_exists(SomeGlobalClassWithoutMethods::class, 'testStaticMethod')); + } + + public function testSetObjectPropertyUnsetClosure(): void + { + $someNamespacedClassWithoutMethods = new SomeNamespacedClassWithoutMethods(); + $resetSetObjectProperty = $this->setObjectProperty($someNamespacedClassWithoutMethods, 'number', 89); + $resetStaticSetObjectProperty = $this->setObjectProperty( + SomeNamespacedClassWithoutMethods::class, + 'name', + 'Bob' + ); + + $this->assertEquals(89, $this->getObjectProperty($someNamespacedClassWithoutMethods, 'number')); + $this->assertEquals('Bob', $this->getObjectProperty(SomeNamespacedClassWithoutMethods::class, 'name')); + + $resetSetObjectProperty(); + $resetStaticSetObjectProperty(); + + $this->assertEquals(23, $this->getObjectProperty($someNamespacedClassWithoutMethods, 'number')); + $this->assertEquals('Luca', $this->getObjectProperty(SomeNamespacedClassWithoutMethods::class, 'name')); + } + + public function testSetMethodStaticVariablesUnsetClosure(): void + { + $someNamespacedClassWithStaticVariables = new NamespacedClassWithStaticVariables(); + $resetSetMethodStaticVariables = $this->setMethodStaticVariables( + NamespacedClassWithStaticVariables::class, + 'theCounter', + ['counter' => 23] + ); + $resetStaticSetMethodStaticVariables = $this->setMethodStaticVariables( + NamespacedClassWithStaticVariables::class, + 'theStaticCounter', + ['counter' => 89] + ); + + $this->assertEquals(['counter' => 23], + $this->getMethodStaticVariables(NamespacedClassWithStaticVariables::class, 'theCounter')); + $this->assertEquals(['counter' => 89], + $this->getMethodStaticVariables(NamespacedClassWithStaticVariables::class, 'theStaticCounter')); + + $resetSetMethodStaticVariables(); + $resetStaticSetMethodStaticVariables(); + + $this->assertEquals(['counter' => 0], + $this->getMethodStaticVariables(NamespacedClassWithStaticVariables::class, 'theCounter')); + $this->assertEquals(['counter' => 0], + $this->getMethodStaticVariables(NamespacedClassWithStaticVariables::class, 'theStaticCounter')); + } + + public function testSetFunctionStaticVariablesUnsetClosure(): void + { + $resetFunctionStaticVariables = $this->setFunctionStaticVariables( + 'withStaticVariableTwo', + ['counter' => 23, 'step' => 89] + ); + + $this->assertEquals(['counter' => 23, 'step' => 89], + $this->getFunctionStaticVariables('withStaticVariableTwo')); + + $resetFunctionStaticVariables(); + + $this->assertEquals(['counter' => 0, 'step' => 2], $this->getFunctionStaticVariables('withStaticVariableTwo')); + } + + public function testAddFunctionRemoveClosure(): void + { + $removeFunction = $this->addFunction('someTestFunctionOfMine', function (): int { + return 23; + }); + + $this->assertTrue(function_exists('someTestFunctionOfMine')); + $this->assertEquals(23, someTestFunctionOfMine()); + + $removeFunction(); + + $this->assertFalse(function_exists('someTestFunctionOfMine')); + } } diff --git a/tests/unit/lucatume/WPBrowser/Utils/FilesystemTest.php b/tests/unit/lucatume/WPBrowser/Utils/FilesystemTest.php index 05053fa5e..9ba768174 100644 --- a/tests/unit/lucatume/WPBrowser/Utils/FilesystemTest.php +++ b/tests/unit/lucatume/WPBrowser/Utils/FilesystemTest.php @@ -256,4 +256,25 @@ public function test_relativePath(\Closure $fixture): void $fullRelPath = $from . '/' . $expected; $this->assertFileExists(str_replace('\\', '/', $fullRelPath)); } + + public static function normalizePathDataProvider(): array + { + return [ + ['/foo/bar/baz', '/foo/bar/baz'], + ['C:\\foo\\bar\\baz', 'C:/foo/bar/baz'], + ['C:/foo/bar/baz', 'C:/foo/bar/baz'], + ['file:///foo/bar/baz', 'file:///foo/bar/baz'], + ['file://C:/foo/bar/baz', 'file://C:/foo/bar/baz'], + ['file://C:\\foo\\bar\\baz', 'file://C:/foo/bar/baz'], + ['c:\\foo\\bar/baz', 'C:/foo/bar/baz'], + ]; + } + + /** + * @dataProvider normalizePathDataProvider + */ + public function testNormalizePath(string $path, string $expected): void + { + $this->assertEquals($expected, Filesystem::normalizePath($path)); + } } diff --git a/tests/unit/lucatume/WPBrowser/Utils/MachineInformationTest.php b/tests/unit/lucatume/WPBrowser/Utils/MachineInformationTest.php new file mode 100644 index 000000000..456287e54 --- /dev/null +++ b/tests/unit/lucatume/WPBrowser/Utils/MachineInformationTest.php @@ -0,0 +1,97 @@ +assertEquals($os, $machineInformation->getOperatingSystem()); + $this->assertEquals($arch, $machineInformation->getArchitecture()); + } + + public function testGetOperatingSystemDataProvider(): array + { + return [ + ['Linux', MachineInformation::OS_LINUX], + ['Windows', MachineInformation::OS_WINDOWS], + ['Darwin', MachineInformation::OS_DARWIN], + ['Unknown', MachineInformation::OS_UNKNOWN], + ]; + } + + /** + * @dataProvider testGetOperatingSystemDataProvider + */ + public function testGetOperatingSystem(string $uname, string $expected): void + { + $this->setFunctionReturn('php_uname', fn($arg) => $arg === 's' ? $uname : php_uname($arg), true); + + $machineInformation = new MachineInformation(); + + $this->assertEquals($expected, $machineInformation->getOperatingSystem()); + } + + public function testGetArchitectureDataProvider(): array + { + return [ + ['x86_64', MachineInformation::ARCH_X86_64], + ['amd64', MachineInformation::ARCH_X86_64], + ['arm64', MachineInformation::ARCH_ARM64], + ['aarch64', MachineInformation::ARCH_ARM64], + ['Unknown', MachineInformation::ARCH_UNKNOWN], + ]; + } + + /** + * @dataProvider testGetArchitectureDataProvider + */ + public function testGetArchitecture(string $uname, string $expected): void + { + $this->setFunctionReturn('php_uname', fn($arg) => $arg === 'm' ? $uname : php_uname($arg), true); + + $machineInformation = new MachineInformation(); + + $this->assertEquals($expected, $machineInformation->getArchitecture()); + } + + public function testIsWindows(): void + { + $mockUname = 'linux'; + $this->setFunctionReturn('php_uname', function ($arg) use (&$mockUname) { + return $arg === 's' ? $mockUname : php_uname($arg); + }, true); + + $machineInformation = new MachineInformation(); + + $this->assertFalse($machineInformation->isWindows()); + + $mockUname = 'windows'; + $machineInformation = new MachineInformation(); + + $this->assertTrue($machineInformation->isWindows()); + } +} From 6099f376ab5b5887b15c5e73188a7cb5c2ca3f2c Mon Sep 17 00:00:00 2001 From: Luca Tumedei Date: Sat, 17 Aug 2024 12:10:53 +0200 Subject: [PATCH 19/30] fix(ChromeDriver) more robust start message --- src/ManagedProcess/ChromeDriver.php | 2 +- .../lucatume/WPBrowser/ManagedProcess/ChromedriverTest.php | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/ManagedProcess/ChromeDriver.php b/src/ManagedProcess/ChromeDriver.php index 5e29ca66d..415a1793c 100644 --- a/src/ManagedProcess/ChromeDriver.php +++ b/src/ManagedProcess/ChromeDriver.php @@ -63,7 +63,7 @@ private function confirmStart(Process $process): void $start = time(); $output = $process->getOutput(); while (time() < $start + 30) { - if (str_contains($output, 'ChromeDriver was started successfully.')) { + if (str_contains($output, 'ChromeDriver was started successfully')) { return; } if ($process->getExitCode() !== null) { diff --git a/tests/unit/lucatume/WPBrowser/ManagedProcess/ChromedriverTest.php b/tests/unit/lucatume/WPBrowser/ManagedProcess/ChromedriverTest.php index bf74a65b7..b3cfd60e0 100644 --- a/tests/unit/lucatume/WPBrowser/ManagedProcess/ChromedriverTest.php +++ b/tests/unit/lucatume/WPBrowser/ManagedProcess/ChromedriverTest.php @@ -89,7 +89,7 @@ public function should_throw_if_binary_cannot_be_started_with_arguments(): void public function should_throw_if_pid_is_not_integer_on_start(): void { $mockProcess = $this->makeEmpty(Process::class, [ - 'getOutput' => 'ChromeDriver was started successfully.', + 'getOutput' => 'ChromeDriver was started successfully', 'getPid' => null, 'isRunning' => true, 'stop' => 5 @@ -112,7 +112,7 @@ public function should_throw_if_pid_is_not_integer_on_start(): void public function should_throw_if_pif_file_cannot_be_written_on_start(): void { $mockProcess = $this->makeEmpty(Process::class,[ - 'getOutput' => 'ChromeDriver was started successfully.', + 'getOutput' => 'ChromeDriver was started successfully', 'isRunning' => true, 'getPid' => 2389, ]); From 2493a3d55cb3053680716db309b3812c60f402c5 Mon Sep 17 00:00:00 2001 From: Luca Tumedei Date: Sat, 17 Aug 2024 12:11:09 +0200 Subject: [PATCH 20/30] build(.github) install netstat if not installed --- .github/workflows/test.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index dbf7f5411..6d0e3bdf0 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -85,6 +85,9 @@ jobs: run: | ${{ steps.setup-chrome.outputs.chrome-path }} --version + - name: Ensure netstat is installed if not installed + run: which netstat || sudo apt-get install net-tools + - name: Get the vendor/bin directory path id: vendor-bin-dir run: | From 0151a3df16770dbe99425ff83f1c491547e1ddc9 Mon Sep 17 00:00:00 2001 From: Luca Tumedei Date: Sat, 17 Aug 2024 17:18:04 +0200 Subject: [PATCH 21/30] fix(MysqlServer) add log dir init start parameter --- docs/extensions/MySqlServerController.md | 4 +- src/ManagedProcess/MysqlServer.php | 41 ++++-- .../ManagedProcess/MysqlServerTest.php | 118 ++++++++++++++++++ 3 files changed, 153 insertions(+), 10 deletions(-) diff --git a/docs/extensions/MySqlServerController.md b/docs/extensions/MySqlServerController.md index 7350e368f..9b4ade2c2 100644 --- a/docs/extensions/MySqlServerController.md +++ b/docs/extensions/MySqlServerController.md @@ -22,14 +22,14 @@ The extension can be configured with the following parameters: * `port` - the localhost port to use for the MySQL server, defaults to `8906`. * `database` - the database that will be created when starting the server, defaults to `wordpress`. * `user` - the user that will be created when starting the server, defaults to `wordpress`. The user will be granted - all privileges on the database specified by the `database` parameter. If the user is `root`, no furhter user will + all privileges on the database specified by the `database` parameter. If the user is `root`, no further user will be created. * `password` - the password to use for the user specified by the `user` parameter, defaults to `wordpress`. If the user is `root`, the root user will be set to the password specified by this parameter. * optional * `suites` - an array of Codeception suites to run the server for; if not set the server will be started for all the suites. - * `binary` - the path to the MySQL server binary to use, defaults to `mysqld`, deafults to `null` to download and + * `binary` - the path to the MySQL server binary to use, defaults to `mysqld`, defaults to `null` to download and initialize the correct version of MySQL server for the current platform and architecture. * `shareDir` - the path to the directory to use for the MySQL server share, defaults to `null`. **This is required when providing a custom binary**. diff --git a/src/ManagedProcess/MysqlServer.php b/src/ManagedProcess/MysqlServer.php index 5d67f775d..c2cd54d33 100644 --- a/src/ManagedProcess/MysqlServer.php +++ b/src/ManagedProcess/MysqlServer.php @@ -21,15 +21,14 @@ class MysqlServer implements ManagedProcessInterface public const ERR_OS_NOT_SUPPORTED = 1; public const ERR_ARCH_NOT_SUPPORTED = 2; public const ERR_WINDOWS_ARM64_NOT_SUPPORTED = 3; - public const ERR_ARCHIVE_NOT_FOUND = 9; public const ERR_MYSQL_DIR_NOT_CREATED = 10; public const ERR_MYSQL_ARCHIVE_EXTRACTION_FAILED = 11; public const ERR_CUSTOM_BINARY_EXTRACTED_PATH = 12; public const ERR_CUSTOM_BINARY_SHARE_DIR_PATH = 13; - public const ERR_CUSTOM_BINARY_DOWNLOAD = 14; public const ERR_MYSQL_ARCHIVE_DOWNLOAD_FAILED = 15; public const ERR_MYSQL_SERVER_START_FAILED = 16; public const ERR_MYSQL_DATA_DIR_NOT_CREATED = 17; + public const ERR_MYSQL_SERVER_NEVER_BECAME_AVAILABLE = 18; private string $directory; private ?string $binary; private string $pidFile; @@ -38,6 +37,7 @@ class MysqlServer implements ManagedProcessInterface private ?string $customShareDir = null; private string $prettyName = 'MySQL Server'; private ?OutputInterface $output = null; + private float $startWaitTime = 10; /** * @throws RuntimeException @@ -91,6 +91,11 @@ public function setMachineInformation(MachineInformation $machineInformation): v $this->machineInformation = $machineInformation; } + public function setStartWaitTime(float $param): void + { + $this->startWaitTime = $param; + } + public function getDataDir(bool $normalize = false): string { $isWin = $this->machineInformation->isWindows(); @@ -201,6 +206,7 @@ private function getStartCommand(int $port, bool $normalize = false): array '--bind-address=localhost', '--lc-messages-dir=' . $this->getShareDir($normalize), '--socket=' . $this->getSocketPath($normalize), + '--log-error=' . $this->getErrorLogPath($normalize), '--port=' . $port, '--pid-file=' . $this->getPidFilePath($normalize) ]; @@ -220,12 +226,14 @@ private function startServer(int $port): Process $process = new Process($startCommand); $process->createNewConsole(); try { - // Try to start the server 40 times, 10 seconds apart. - $tries = 40; $process->start(); - while (!$this->getRootPDOOrNot() && $tries--) { - // Sleep a .25 seconds to allow the server to start. - usleep(250000); + $startTime = microtime(true); + $pdo = $this->getRootPDOOrNot(); + $sleepTime = $this->startWaitTime / 10; + $sleepTimeInMicroseconds = min((int)($sleepTime * 1000000), 1000000); + while (!$pdo && (microtime(true) - $startTime) < $this->startWaitTime) { + usleep($sleepTimeInMicroseconds); + $pdo = $this->getRootPDOOrNot(); } } catch (\Exception $e) { throw new RuntimeException( @@ -234,6 +242,15 @@ private function startServer(int $port): Process $e ); } + + if ($pdo === null) { + throw new RuntimeException( + "MySQL Server was started but never became available.\n" . $process->getOutput() . "\n" . + $process->getErrorOutput(), + self::ERR_MYSQL_SERVER_NEVER_BECAME_AVAILABLE + ); + } + return $process; } @@ -241,7 +258,7 @@ private function getRootPDOOrNot(): ?\PDO { try { return $this->getRootPDO(); - } catch (\Throwable) { + } catch (\Throwable $e) { return null; } } @@ -544,4 +561,12 @@ public function getDirectory(bool $normalize = false): string str_replace('/', '\\', $this->directory) : $this->directory; } + + public function getErrorLogPath(bool $normalize = false): string + { + $path = $this->getDataDir(false) . '/error.log'; + return !$normalize && $this->machineInformation->isWindows() ? + str_replace('/', '\\', $path) + : $path; + } } diff --git a/tests/unit/lucatume/WPBrowser/ManagedProcess/MysqlServerTest.php b/tests/unit/lucatume/WPBrowser/ManagedProcess/MysqlServerTest.php index 3fdb721dd..1060814e1 100644 --- a/tests/unit/lucatume/WPBrowser/ManagedProcess/MysqlServerTest.php +++ b/tests/unit/lucatume/WPBrowser/ManagedProcess/MysqlServerTest.php @@ -442,6 +442,7 @@ function (string $url, string $file) use ($mysqlServer): void { '--bind-address=localhost', '--lc-messages-dir=' . $mysqlServer->getShareDir(), '--socket=' . $mysqlServer->getSocketPath(), + '--log-error=' . $mysqlServer->getErrorLogPath(), '--port=' . $mysqlServer->getPort(), '--pid-file=' . $mysqlServer->getPidFilePath() ], $command); @@ -603,6 +604,7 @@ function (string $url, string $file) use ($mysqlServer): void { '--bind-address=localhost', '--lc-messages-dir=' . $mysqlServer->getShareDir(), '--socket=' . $mysqlServer->getSocketPath(), + '--log-error=' . $mysqlServer->getErrorLogPath(), '--port=' . $mysqlServer->getPort(), '--pid-file=' . $mysqlServer->getPidFilePath() ], $command); @@ -782,6 +784,7 @@ function (string $url, string $file) use ($mysqlServer): void { '--bind-address=localhost', '--lc-messages-dir=' . $mysqlServer->getShareDir(), '--socket=' . $mysqlServer->getSocketPath(), + '--log-error=' . $mysqlServer->getErrorLogPath(), '--port=' . $mysqlServer->getPort(), '--pid-file=' . $mysqlServer->getPidFilePath() ], $command); @@ -943,4 +946,119 @@ public function testStopThrowsIfPidFileCannotBeUnlinked(): void $mysqlServer->stop(); } + + public function testStartThrowsIfServerIsNotAvailable(): void + { + ($this->unsetMkdirFunctionReturn)(); + $dir = FS::tmpDir('mysql-server_'); + $mysqlServer = new MysqlServer($dir); + $mysqlServer->setStartWaitTime(.01); + $machineInformation = new MachineInformation(MachineInformation::OS_LINUX, MachineInformation::ARCH_X86_64); + $mysqlServer->setMachineInformation($machineInformation); + + // Mock the download of the archive. + $this->setMethodReturn( + Download::class, + 'fileFromUrl', + function (string $url, string $file) use ($mysqlServer): void { + Assert::assertEquals($mysqlServer->getArchiveUrl(), $url); + Assert::assertEquals($mysqlServer->getArchivePath(true), $file); + $archiveBasename = basename($mysqlServer->getArchiveUrl()); + copy(codecept_data_dir('mysql-server/mock-archives/' . $archiveBasename), $file); + }, + true + ); + + // Mock the processes to initialize and start the server. + $mockProcessStep = $machineInformation->isWindows() ? 'init' : 'extract'; + $this->setClassMock( + Process::class, + $this->makeEmptyClass( + Process::class, + [ + '__construct' => function (array $command) use (&$mockProcessStep, $mysqlServer) { + if ($mockProcessStep === 'extract') { + Assert::assertEquals([ + 'tar', + '-xf', + $mysqlServer->getArchivePath(), + '-C', + $mysqlServer->getDirectory(), + ], $command); + $mockProcessStep = 'init'; + $extractedPath = $mysqlServer->getExtractedPath(true); + mkdir($extractedPath . '/share', 0777, true); + mkdir($extractedPath . '/bin', 0777, true); + touch($extractedPath . '/bin/mysqld'); + chmod($extractedPath . '/bin/mysqld', 0777); + return; + } + + if ($mockProcessStep === 'init') { + Assert::assertEquals([ + $mysqlServer->getBinaryPath(), + '--no-defaults', + '--initialize-insecure', + '--innodb-flush-method=nosync', + '--datadir=' . $mysqlServer->getDataDir(), + '--pid-file=' . $mysqlServer->getPidFilePath(), + ], $command); + $mockProcessStep = 'start'; + return; + } + + if ($mockProcessStep === 'start') { + Assert::assertEquals([ + $mysqlServer->getBinaryPath(), + '--datadir=' . $mysqlServer->getDataDir(), + '--skip-mysqlx', + '--default-time-zone=+00:00', + '--innodb-flush-method=nosync', + '--innodb-flush-log-at-trx-commit=0', + '--innodb-doublewrite=0', + '--bind-address=localhost', + '--lc-messages-dir=' . $mysqlServer->getShareDir(), + '--socket=' . $mysqlServer->getSocketPath(), + '--log-error=' . $mysqlServer->getErrorLogPath(), + '--port=' . $mysqlServer->getPort(), + '--pid-file=' . $mysqlServer->getPidFilePath() + ], $command); + $mockProcessStep = 'started'; + return; + } + + throw new AssertionFailedError( + 'Unexpected Process::__construct call for ' . print_r($command, true) + ); + }, + 'mustRun' => '__itself', + 'isRunning' => function () use (&$mockProcessStep): bool { + return $mockProcessStep === 'started'; + }, + 'getPid' => 2389, + 'stop' => function () use (&$mockProcessStep): int { + Assert::assertTrue(in_array($mockProcessStep, ['started', 'stopped'], true)); + $mockProcessStep = 'stopped'; + return 0; + } + ] + )); + + // Mock the PDO connection. + $queries = []; + $this->setClassMock(PDO::class, $this->makeEmptyClass(PDO::class, [ + '__construct' => function () { + throw new \PDOException('Cannot connect to MySQL server'); + }, + 'exec' => function (string $query) use (&$queries): int|false { + $queries[] = $query; + return 1; + } + ])); + + $this->expectException(RuntimeException::class); + $this->expectExceptionCode(MysqlServer::ERR_MYSQL_SERVER_NEVER_BECAME_AVAILABLE); + + $mysqlServer->start(); + } } From 95731a2f1de8ee284b8f35208726855f1f279f5e Mon Sep 17 00:00:00 2001 From: Luca Tumedei Date: Sat, 17 Aug 2024 17:18:22 +0200 Subject: [PATCH 22/30] build(test.yaml) restore the matrix --- .github/workflows/test.yaml | 46 ++++++++++++------------------------- 1 file changed, 15 insertions(+), 31 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 6d0e3bdf0..a4185461a 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -31,23 +31,20 @@ jobs: test: strategy: matrix: -# php_version: [ '8.0', '8.1', '8.2', '8.3' ] -# suite: -# - acceptance -# - climodule -# - functional -# - muloader -# - unit --skip-group=slow -# - unit --group=isolated-1 -# - unit --group=isolated-2 -# - webdriver -# - wpcli_module -# - wploader_multisite -# - wploader_wpdb_interaction -# - wploadersuite - php_version: [ '8.0'] - suite: - - muloader + php_version: [ '8.0', '8.1', '8.2', '8.3' ] + suite: + - acceptance + - climodule + - functional + - muloader + - unit --skip-group=slow + - unit --group=isolated-1 + - unit --group=isolated-2 + - webdriver + - wpcli_module + - wploader_multisite + - wploader_wpdb_interaction + - wploadersuite name: ${{ matrix.suite }} php@${{ matrix.php_version }} runs-on: ubuntu-22.04 steps: @@ -75,19 +72,6 @@ jobs: restore-keys: | ${{ runner.os }}-composer- - - name: Setup Chrome - id: setup-chrome - uses: browser-actions/setup-chrome@v1 - with: - install-dependencies: true - - - name: Check Chrome version and path - run: | - ${{ steps.setup-chrome.outputs.chrome-path }} --version - - - name: Ensure netstat is installed if not installed - run: which netstat || sudo apt-get install net-tools - - name: Get the vendor/bin directory path id: vendor-bin-dir run: | @@ -110,7 +94,7 @@ jobs: - name: Update ChromeDriver if required if: steps.vendor-bin-dir-cache.outputs.cache-hit != 'true' - run: vendor/bin/codecept chromedriver:update --binary ${{ steps.setup-chrome.outputs.chrome-path }} + run: vendor/bin/codecept chromedriver:update --binary /usr/bin/google-chrome - name: Create var/wordpress directory run: mkdir -p var/wordpress From df7cb517e58498cae169a78b1d60fba2bf3d1711 Mon Sep 17 00:00:00 2001 From: Luca Tumedei Date: Mon, 19 Aug 2024 08:44:16 +0200 Subject: [PATCH 23/30] test(DockerComposerController) update and fix --- .../Extension/DockerComposeControllerTest.php | 325 +++++++++++++----- 1 file changed, 246 insertions(+), 79 deletions(-) diff --git a/tests/unit/lucatume/WPBrowser/Extension/DockerComposeControllerTest.php b/tests/unit/lucatume/WPBrowser/Extension/DockerComposeControllerTest.php index 183e11e05..5c9af1529 100644 --- a/tests/unit/lucatume/WPBrowser/Extension/DockerComposeControllerTest.php +++ b/tests/unit/lucatume/WPBrowser/Extension/DockerComposeControllerTest.php @@ -13,6 +13,8 @@ use lucatume\WPBrowser\Tests\Traits\ClassStubs; use lucatume\WPBrowser\Traits\UopzFunctions; use lucatume\WPBrowser\Utils\Composer; +use PHPUnit\Framework\Assert; +use PHPUnit\Framework\AssertionFailedError; use stdClass; use Symfony\Component\Yaml\Yaml; use tad\Codeception\SnapshotAssertions\SnapshotAssertions; @@ -48,30 +50,32 @@ public function _before() // Silence output. $this->output = new Output(['verbosity' => Output::VERBOSITY_QUIET]); $this->setClassMock(Output::class, $this->output); - } - - /** - * @before - */ - public static function backupRunFile(): void - { - $runFile = DockerComposeController::getRunningFile(); - - if (is_file($runFile)) { - rename($runFile, $runFile . '.bak'); - } - } - - /** - * @after - */ - public static function restoreRunFile(): void - { - $runFile = DockerComposeController::getRunningFile(); - - if (is_file($runFile . '.bak')) { - rename($runFile . '.bak', $runFile); - } + // Intercept reading and writing of the running file. + $runningFile = DockerComposeController::getRunningFile(); + $this->setFunctionReturn('is_file', function (string $file) use ($runningFile): bool { + return $file === $runningFile ? false : is_file($file); + }, true); + $this->setFunctionReturn( + 'file_put_contents', + function (string $file, string $contents) use ($runningFile): bool { + if ($file === $runningFile) { + return false; + } + return file_put_contents($file, $contents); + }, + true + ); + $this->setFunctionReturn('unlink', function (string $file) use ($runningFile): bool { + if ($file === $runningFile) { + return true; + } + return unlink($file); + }, true); + $this->setClassMock(Process::class, $this->makeEmptyClass(Process::class, [ + '__construct' => function (...$args) { + throw new AssertionFailedError('Unexpected Process::__construct call for ' . print_r($args, true)); + } + ])); } public function notArrayOfStringsProvider(): array @@ -115,13 +119,16 @@ public function should_throw_if_suite_configuration_parameter_is_not_array_of_st */ public function should_not_run_any_command_if_already_running(): void { - file_put_contents(DockerComposeController::getRunningFile(), 'yes'); + $this->setFunctionReturn('is_file', function (string $file): bool { + return $file === DockerComposeController::getRunningFile() ? true : is_file($file); + }, true); $constructed = 0; $this->setClassMock( Process::class, $this->makeEmptyClass(Process::class, [ '__construct' => static function (...$args) use (&$constructed) { $constructed++; + throw new AssertionFailedError('Unexpected Process::__construct call for ' . print_r($args, true)); } ]) ); @@ -130,7 +137,6 @@ public function should_not_run_any_command_if_already_running(): void $options = []; $extension = new DockerComposeController($config, $options); - $mockSuite = $this->make(Suite::class, ['getName' => 'end2end']); $extension->onModuleInit($this->make(SuiteEvent::class, ['getSuite' => $mockSuite])); @@ -138,20 +144,42 @@ public function should_not_run_any_command_if_already_running(): void } /** - * It should up stack correctly + * It should start stack correctly * * @test */ - public function should_up_stack_correctly(): void + public function should_start_the_stack_correctly(): void { - $constructCommands = []; + $runningFileExists = false; + $this->setFunctionReturn('is_file', function (string $file) use ($runningFileExists): bool { + return $file === DockerComposeController::getRunningFile() ? $runningFileExists : is_file($file); + }, true); + $this->setFunctionReturn( + 'file_put_contents', + function (string $file, string $contents) use (&$runningFileExists): bool { + if ($file === DockerComposeController::getRunningFile()) { + $runningFileExists = true; + return true; + } + return file_put_contents($file, $contents); + }, + true + ); $this->setClassMock( Process::class, $this->makeEmptyClass(Process::class, [ - '__construct' => static function ($command, ...$args) use (&$constructCommands) { - $constructCommands[] = $command; + '__construct' => static function ($command, ...$args) use (&$constructedProcesses) { + Assert::assertEquals([ + 'docker', + 'compose', + '-f', + 'docker-compose.yml', + 'up', + '--wait' + ], $command); }, - 'mustRun' => '__itself' + 'mustRun' => '__itself', + 'getPid' => 2389, ]) ); @@ -159,15 +187,10 @@ public function should_up_stack_correctly(): void $options = []; $extension = new DockerComposeController($config, $options); - $mockSuite = $this->make(Suite::class, ['getName' => 'end2end']); $extension->onModuleInit($this->make(SuiteEvent::class, ['getSuite' => $mockSuite])); - $this->assertEquals( - ['docker', 'compose', '-f', 'docker-compose.yml', 'up', '--wait'], - $constructCommands[0] - ); - $this->assertFileExists(DockerComposeController::getRunningFile()); + $this->assertTrue($runningFileExists); } /** @@ -177,8 +200,6 @@ public function should_up_stack_correctly(): void */ public function should_throw_if_config_compose_file_is_not_valid_existing_file(): void { - $this->setClassMock(Process::class, $this->makeEmptyClass(Process::class, [])); - $config = ['suites' => ['end2end'], 'compose-file' => 'not-a-file.yml']; $options = []; @@ -199,8 +220,6 @@ public function should_throw_if_config_compose_file_is_not_valid_existing_file() */ public function should_throw_if_config_env_file_is_not_valid_file(): void { - $this->setClassMock(Process::class, $this->makeEmptyClass(Process::class, [])); - $config = ['suites' => ['end2end'], 'compose-file' => 'docker-compose.yml', 'env-file' => 'not-an-env-file']; $options = []; @@ -221,15 +240,58 @@ public function should_throw_if_config_env_file_is_not_valid_file(): void */ public function should_correctly_handle_stack_lifecycle(): void { - $constructed = 0; + $runningFileExists = false; + $this->setFunctionReturn('is_file', function (string $file) use (&$runningFileExists): bool { + return $file === DockerComposeController::getRunningFile() ? $runningFileExists : is_file($file); + }, true); + $this->setFunctionReturn( + 'file_put_contents', + function (string $file, string $contents) use (&$runningFileExists): bool { + if ($file === DockerComposeController::getRunningFile()) { + $runningFileExists = true; + return true; + } + return file_put_contents($file, $contents); + }, + true + ); + $this->setFunctionReturn('unlink', function (string $file) use (&$runningFileExists): bool { + if ($file === DockerComposeController::getRunningFile()) { + $runningFileExists = false; + return true; + } + return unlink($file); + }, true); + $step = 'not-running'; $this->setClassMock( Process::class, $this->makeEmptyClass(Process::class, [ - '__construct' => static function () use (&$constructed) { - $constructed++; + '__construct' => static function ($command) use (&$step) { + if ($step === 'not-running') { + $step = 'started'; + Assert::assertEquals([ + 'docker', + 'compose', + '-f', + 'docker-compose.yml', + 'up', + '--wait' + ], $command); + return; + } + + $step = 'stopped'; + Assert::assertEquals([ + 'docker', + 'compose', + '-f', + 'docker-compose.yml', + 'down' + ], $command); }, 'mustRun' => '__itself', - 'stop' => 0 + 'stop' => 0, + 'getPid' => 2389 ]) ); $config = ['suites' => ['end2end'], 'compose-file' => 'docker-compose.yml']; @@ -238,15 +300,13 @@ public function should_correctly_handle_stack_lifecycle(): void $extension = new DockerComposeController($config, $options); $mockSuite = $this->make(Suite::class, ['getName' => 'end2end']); - $extension->onModuleInit($this->make(SuiteEvent::class, ['getSuite' => $mockSuite])); - $this->assertEquals(1, $constructed); - $this->assertFileExists(DockerComposeController::getRunningFile()); + $this->assertTrue($runningFileExists); $extension->stop($this->output); - $this->assertFileNotExists(DockerComposeController::getRunningFile()); + $this->assertFalse($runningFileExists); $extension->stop($this->output); } @@ -262,7 +322,7 @@ public function should_throw_if_docker_compose_start_fails(): void Process::class, $this->makeEmptyClass(Process::class, [ 'mustRun' => static function () { - throw new Exception('something went wrong'); + throw new Exception('Something went wrong.'); } ]) ); @@ -285,8 +345,32 @@ public function should_throw_if_docker_compose_start_fails(): void */ public function should_throw_if_running_file_cannot_be_written(): void { + $this->setFunctionReturn('is_file', function (string $file): bool { + return $file === DockerComposeController::getRunningFile() ? false : is_file($file); + }, true); + $this->setFunctionReturn( + 'file_put_contents', + function (string $file, string $contents): bool { + if ($file === DockerComposeController::getRunningFile()) { + return false; + } + return file_put_contents($file, $contents); + }, + true + ); $this->setClassMock(Process::class, $this->makeEmptyClass(Process::class, [ + '__construct' => static function ($command) { + Assert::assertEquals([ + 'docker', + 'compose', + '-f', + 'docker-compose.yml', + 'up', + '--wait' + ], $command); + }, 'mustRun' => '__itself', + 'getPid' => 2389 ])); $config = ['suites' => ['end2end'], 'compose-file' => 'docker-compose.yml']; $options = []; @@ -297,7 +381,6 @@ public function should_throw_if_running_file_cannot_be_written(): void $this->expectException(ExtensionException::class); $this->expectExceptionMessage('Failed to write Docker Compose running file.'); - $this->setFunctionReturn('file_put_contents', false); $extension->onModuleInit($this->make(SuiteEvent::class, ['getSuite' => $mockSuite])); } @@ -309,25 +392,30 @@ public function should_throw_if_running_file_cannot_be_written(): void */ public function should_throw_if_stack_stopping_fails(): void { - $config = ['suites' => ['end2end'], 'compose-file' => 'docker-compose.yml']; - $options = []; - - $extension = new DockerComposeController($config, $options); - - $mockSuite = $this->make(Suite::class, ['getName' => 'end2end']); - - $extension->onModuleInit($this->make(SuiteEvent::class, ['getSuite' => $mockSuite])); - - $this->assertFileExists(DockerComposeController::getRunningFile()); - + $this->setFunctionReturn('is_file', function (string $file): bool { + return $file === DockerComposeController::getRunningFile() ? true : is_file($file); + }, true); $this->setClassMock( Process::class, $this->makeEmptyClass(Process::class, [ - 'mustRun' => static function () { - throw new Exception('something went wrong'); + '__construct' => static function ($command) { + Assert::assertEquals([ + 'docker', + 'compose', + '-f', + 'docker-compose.yml', + 'down' + ], $command); + }, + 'mustRun' => function () { + throw new Exception('Failed to stop Docker Compose.'); } ]) ); + $config = ['suites' => ['end2end'], 'compose-file' => 'docker-compose.yml']; + $options = []; + + $extension = new DockerComposeController($config, $options); $this->expectException(ExtensionException::class); $this->expectExceptionMessageRegExp('/Failed to stop Docker Compose/'); @@ -342,26 +430,35 @@ public function should_throw_if_stack_stopping_fails(): void */ public function should_throw_if_running_file_cannot_be_removed_while_stopping(): void { + $this->setFunctionReturn('is_file', function (string $file): bool { + return $file === DockerComposeController::getRunningFile() ? true : is_file($file); + }, true); $this->setClassMock( Process::class, $this->makeEmptyClass(Process::class, [ - 'mustRun' => '__itself', - 'stop' => 0 + '__construct' => static function ($command) { + Assert::assertEquals([ + 'docker', + 'compose', + '-f', + 'docker-compose.yml', + 'down' + ], $command); + }, + 'mustRun' => '__itself' ]) ); + $this->setFunctionReturn('unlink', function ($file) { + if ($file === DockerComposeController::getRunningFile()) { + return false; + } + return unlink($file); + }, true); $config = ['suites' => ['end2end'], 'compose-file' => 'docker-compose.yml']; $options = []; $extension = new DockerComposeController($config, $options); - $mockSuite = $this->make(Suite::class, ['getName' => 'end2end']); - - $extension->onModuleInit($this->make(SuiteEvent::class, ['getSuite' => $mockSuite])); - - $this->assertFileExists(DockerComposeController::getRunningFile()); - - $this->setFunctionReturn('unlink', false); - $this->expectException(ExtensionException::class); $this->expectExceptionMessage('Failed to remove Docker Compose running file.'); @@ -375,14 +472,83 @@ public function should_throw_if_running_file_cannot_be_removed_while_stopping(): */ public function should_produce_information_correctly(): void { + $runningFileExists = false; + $this->setFunctionReturn('is_file', function (string $file) use (&$runningFileExists): bool { + return $file === DockerComposeController::getRunningFile() ? $runningFileExists : is_file($file); + }, true); + $this->setFunctionReturn( + 'file_put_contents', + function (string $file, string $contents) use (&$runningFileExists): bool { + if ($file === DockerComposeController::getRunningFile()) { + $runningFileExists = true; + return true; + } + return file_put_contents($file, $contents); + }, + true + ); + $this->setFunctionReturn('unlink', function (string $file) use (&$runningFileExists): bool { + if ($file === DockerComposeController::getRunningFile()) { + $runningFileExists = false; + return true; + } + return unlink($file); + }, true); + $step = 'not-running'; $this->setClassMock( Process::class, $this->makeEmptyClass(Process::class, [ + '__construct' => static function ($command) use (&$step) { + if ($step === 'not-running') { + $step = 'started-fetch-config'; + Assert::assertEquals([ + 'docker', + 'compose', + '-f', + 'docker-compose.yml', + 'up', + '--wait' + ], $command); + return; + } + + if ($step === 'started-fetch-config') { + $step = 'started'; + Assert::assertEquals([ + 'docker', + 'compose', + '-f', + 'docker-compose.yml', + 'config' + ], $command); + return; + } + + if ($step === 'started') { + $step = 'stopped'; + Assert::assertEquals([ + 'docker', + 'compose', + '-f', + 'docker-compose.yml', + 'down' + ], $command); + return; + } + + throw new AssertionFailedError( + 'Unexpected Process::__construct call for ' . print_r($command, true) + ); + }, 'mustRun' => '__itself', - 'getOutput' => static function () { - return Yaml::dump(['services' => ['foo' => ['ports' => ['8088:80']]]]); + 'getOutput' => static function () use (&$step) { + if ($step === 'started') { + return Yaml::dump(['services' => ['foo' => ['ports' => ['8088:80']]]]); + } + return ''; }, - 'stop' => 0 + 'stop' => 0, + 'getPid' => 2389 ]) ); $config = ['suites' => ['end2end'], 'compose-file' => 'docker-compose.yml']; @@ -394,7 +560,7 @@ public function should_produce_information_correctly(): void $extension->onModuleInit($this->make(SuiteEvent::class, ['getSuite' => $mockSuite])); - $this->assertFileExists(DockerComposeController::getRunningFile()); + $this->assertTrue($runningFileExists); $this->assertEquals( ['status' => 'up', 'config' => ['services' => ['foo' => ['ports' => [0 => '8088:80']]]]], @@ -404,5 +570,6 @@ public function should_produce_information_correctly(): void $extension->stop($this->output); $this->assertEquals(['status' => 'down', 'config' => ''], $extension->getInfo()); + $this->assertFalse($runningFileExists); } } From a019dbbd955eadc0d2f885c97a033cca98ead45e Mon Sep 17 00:00:00 2001 From: Luca Tumedei Date: Mon, 19 Aug 2024 08:56:24 +0200 Subject: [PATCH 24/30] test(MysqlServerTest) update expectations --- .../ManagedProcess/MysqlServerTest.php | 42 ++++++++++--------- 1 file changed, 23 insertions(+), 19 deletions(-) diff --git a/tests/unit/lucatume/WPBrowser/ManagedProcess/MysqlServerTest.php b/tests/unit/lucatume/WPBrowser/ManagedProcess/MysqlServerTest.php index 1060814e1..0f13cc25f 100644 --- a/tests/unit/lucatume/WPBrowser/ManagedProcess/MysqlServerTest.php +++ b/tests/unit/lucatume/WPBrowser/ManagedProcess/MysqlServerTest.php @@ -18,7 +18,6 @@ use PharData; use PHPUnit\Framework\Assert; use PHPUnit\Framework\AssertionFailedError; -use ReflectionMethod; class MysqlServerTest extends Unit { @@ -459,11 +458,7 @@ function (string $url, string $file) use ($mysqlServer): void { return $mockProcessStep === 'started'; }, 'getPid' => 2389, - 'stop' => function () use (&$mockProcessStep): int { - Assert::assertTrue(in_array($mockProcessStep, ['started', 'stopped'], true)); - $mockProcessStep = 'stopped'; - return 0; - } + 'stop' => 0 ] ) ); @@ -914,12 +909,16 @@ public function testStopThrowsIfPidFileCannotBeUnlinked(): void // Mock the PID file write. $pidFileExists = false; - $this->setFunctionReturn('file_put_contents', function (string $file, $pid) use ($pidFile,&$pidFileExists): bool { - Assert::assertEquals($pidFile, $file); - Assert::assertEquals(2389, $pid); - $pidFileExists = true; - return true; - }, true); + $this->setFunctionReturn( + 'file_put_contents', + function (string $file, $pid) use ($pidFile, &$pidFileExists): bool { + Assert::assertEquals($pidFile, $file); + Assert::assertEquals(2389, $pid); + $pidFileExists = true; + return true; + }, + true + ); // The PID file exists. $this->setFunctionReturn('is_file', function (string $file) use (&$pidFileExists, $pidFile): bool { @@ -938,7 +937,15 @@ public function testStopThrowsIfPidFileCannotBeUnlinked(): void 'exec' => 1 ])); - $mysqlServer = new MysqlServer($dir, 12345, 'someDatabase', 'root', 'secret','/usr/bin/mysqld', '/some/share/dir'); + $mysqlServer = new MysqlServer( + $dir, + 12345, + 'someDatabase', + 'root', + 'secret', + '/usr/bin/mysqld', + '/some/share/dir' + ); $mysqlServer->start(); $this->expectException(RuntimeException::class); @@ -1036,13 +1043,10 @@ function (string $url, string $file) use ($mysqlServer): void { return $mockProcessStep === 'started'; }, 'getPid' => 2389, - 'stop' => function () use (&$mockProcessStep): int { - Assert::assertTrue(in_array($mockProcessStep, ['started', 'stopped'], true)); - $mockProcessStep = 'stopped'; - return 0; - } + 'stop' => 0 ] - )); + ) + ); // Mock the PDO connection. $queries = []; From ed09b88f1424703452760f6955b36974aaf6d7b6 Mon Sep 17 00:00:00 2001 From: Luca Tumedei Date: Mon, 19 Aug 2024 09:16:25 +0200 Subject: [PATCH 25/30] build(bin/setup-wp.php) update to use MysqlServer --- bin/setup-wp.php | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/bin/setup-wp.php b/bin/setup-wp.php index 62fa0f39a..9e8e4820c 100644 --- a/bin/setup-wp.php +++ b/bin/setup-wp.php @@ -1,6 +1,7 @@ load(); +$mysqlServer = new MysqlServer( + codecept_output_dir('_mysql-server'), + $_ENV['WORDPRESS_DB_LOCALHOST_PORT'], + $_ENV['WORDPRESS_DB_NAME'], + $_ENV['WORDPRESS_DB_USER'], + $_ENV['WORDPRESS_DB_PASSWORD'],); +$mysqlServer->start(); + $wpRootDir = $env['WORDPRESS_ROOT_DIR']; echo "Checking WordPress directory $wpRootDir ...\n"; From d43a1aeae7573288f3e30f59a280db9d38d0a18c Mon Sep 17 00:00:00 2001 From: Luca Tumedei Date: Mon, 19 Aug 2024 21:24:10 +0200 Subject: [PATCH 26/30] fix(Module/WPDb) set modes on getDbh --- src/Module/WPDb.php | 300 +++++++++++------- .../lucatume/WPBrowser/Module/WPDbTest.php | 156 ++++++++- 2 files changed, 326 insertions(+), 130 deletions(-) diff --git a/src/Module/WPDb.php b/src/Module/WPDb.php index 55335aacc..88b1ca9cf 100644 --- a/src/Module/WPDb.php +++ b/src/Module/WPDb.php @@ -2,6 +2,7 @@ namespace lucatume\WPBrowser\Module; +use Closure; use Codeception\Exception\ModuleConfigException; use Codeception\Exception\ModuleException; use Codeception\Exception\ModuleRequireException; @@ -195,12 +196,14 @@ class WPDb extends Db protected ?string $blogUrl = null; + private ?Closure $modesSetter = null; + /** * WPDb constructor. * * @param ModuleContainer $moduleContainer The module container handling the suite modules. * @param array|null $config The module configuration - * @param DbDump|null $dbDump The database dump handler. + * @param DbDump|null $dbDump The database dump handler. * * @return void */ @@ -219,9 +222,9 @@ public function __construct(ModuleContainer $moduleContainer, ?array $config = n * $insertedId = $I->haveSiteMetaInDatabase(2, 'foo', ['bar' => 'baz']); * ``` * - * @param int $blogId The blog ID. + * @param int $blogId The blog ID. * @param string $string The meta key. - * @param mixed $value The meta value. + * @param mixed $value The meta value. * * @return int The inserted row ID. */ @@ -279,7 +282,7 @@ public function grabSiteMetaFromDatabase(int $blogId, string $key, bool $single) * $type = $I->grabPostFieldFromDatabase(1, 'post_type'); * ``` * - * @param int $postId The post ID. + * @param int $postId The post ID. * @param string $field The post field to get the value for. * * @return mixed The value of the post field. @@ -412,7 +415,7 @@ public function importSqlDumpFile(string $dumpFile = null): void /** * Cleans up the database. * - * @param string|null $databaseKey The key of the database to clean up. + * @param string|null $databaseKey The key of the database to clean up. * @param array|null $databaseConfig The configuration of the database to clean up. * * @@ -439,7 +442,7 @@ public function _cleanup(string $databaseKey = null, array $databaseConfig = nul * ``` * * @param array|string $criteriaOrName An array of search criteria or the option name. - * @param mixed|null $value The optional value to try and match, only used if the option + * @param mixed|null $value The optional value to try and match, only used if the option * name is provided. * * @@ -583,14 +586,14 @@ public function dontSeePostMetaInDatabase(array $criteria): void * $I->seePostWithTermInDatabase($postId, $fiction['term_taxonomy_id']); * ``` * - * @param int $post_id The post ID. - * @param int $term_taxonomy_id The term `term_id` or `term_taxonomy_id`; if the `$taxonomy` argument is + * @param int $post_id The post ID. + * @param int $term_taxonomy_id The term `term_id` or `term_taxonomy_id`; if the `$taxonomy` argument is * passed this parameter will be interpreted as a `term_id`, else as a * `term_taxonomy_id`. - * @param int|null $term_order The order the term applies to the post, defaults to `null` to not use + * @param int|null $term_order The order the term applies to the post, defaults to `null` to not use * the * term order. - * @param string|null $taxonomy The taxonomy the `term_id` is for; if passed this parameter will be used + * @param string|null $taxonomy The taxonomy the `term_id` is for; if passed this parameter will be used * to build a `taxonomy_term_id` from the `term_id`. * * @@ -833,17 +836,23 @@ public function havePostInDatabase(array $data = []): int 'taxonomy' => $taxonomy, ]); - $this->assertIsNumeric($termTaxonomyId, sprintf( - 'Term taxonomy ID for term "%s" in taxonomy "%s" is not numeric', - $termName, - $taxonomy - )); - - $this->assertNotEmpty($termTaxonomyId, sprintf( - 'Term taxonomy ID for term "%s" in taxonomy "%s" is empty', - $termName, - $taxonomy - )); + $this->assertIsNumeric( + $termTaxonomyId, + sprintf( + 'Term taxonomy ID for term "%s" in taxonomy "%s" is not numeric', + $termName, + $taxonomy + ) + ); + + $this->assertNotEmpty( + $termTaxonomyId, + sprintf( + 'Term taxonomy ID for term "%s" in taxonomy "%s" is empty', + $termName, + $taxonomy + ) + ); $this->haveTermRelationshipInDatabase($postId, (int)$termTaxonomyId); $this->increaseTermCountBy((int)$termTaxonomyId, 1); @@ -886,7 +895,7 @@ public function grabPostsTableName(): string * ``` * * @param string $tableName The table to fetch the last insertion for. - * @param string $idColumn The column that is used, in the table, to uniquely identify + * @param string $idColumn The column that is used, in the table, to uniquely identify * items. * * @return int The last insertion id. @@ -917,8 +926,8 @@ public function grabLatestEntryByFromDatabase(string $tableName, string $idColum * } * ``` * - * @param int $postId The post ID. - * @param string $meta_key The meta key. + * @param int $postId The post ID. + * @param string $meta_key The meta key. * @param mixed $meta_value The value to insert in the database, objects and arrays will be serialized. * * @return int The inserted meta `meta_id`. @@ -1020,8 +1029,8 @@ public function grabTermsTableName(): string * ]); * ``` * - * @param string $name The term name, e.g. "Fuzzy". - * @param string $taxonomy The term taxonomy + * @param string $name The term name, e.g. "Fuzzy". + * @param string $taxonomy The term taxonomy * @param array $overrides An array of values to override the default ones. * * @return array An array containing `term_id` and `term_taxonomy_id` of the inserted term. @@ -1097,8 +1106,8 @@ public function grabTermTaxonomyTableName(): string * } * ``` * - * @param int $term_id The ID of the term to insert the meta for. - * @param string $meta_key The key of the meta to insert. + * @param int $term_id The ID of the term to insert the meta for. + * @param string $meta_key The key of the meta to insert. * @param mixed $meta_value The value of the meta to insert, if serializable it will be serialized. * * @return int The inserted term meta `meta_id`. @@ -1176,9 +1185,9 @@ public function grabTermTaxonomyIdFromDatabase(array $criteria): int|false * $I->haveTermRelationshipInDatabase($bookId, $fictionId); * ``` * - * @param int $object_id A post ID, a user ID or anything that can be assigned a taxonomy term. + * @param int $object_id A post ID, a user ID or anything that can be assigned a taxonomy term. * @param int $term_taxonomy_id The `term_taxonomy_id` of the term and taxonomy to create a relation with. - * @param int $term_order Defaults to `0`. + * @param int $term_order Defaults to `0`. */ public function haveTermRelationshipInDatabase(int $object_id, int $term_taxonomy_id, int $term_order = 0): void { @@ -1208,7 +1217,7 @@ public function grabTermRelationshipsTableName(): string * Increases the term counter. * * @param int $termTaxonomyId The ID of the term to increase the count for. - * @param int $by The value to increase the count by. + * @param int $by The value to increase the count by. * * @return bool Whether the update happened correctly or not. * @@ -1216,7 +1225,6 @@ public function grabTermRelationshipsTableName(): string */ protected function increaseTermCountBy(int $termTaxonomyId, int $by = 1): bool { - try { $updateQuery = "UPDATE {$this->grabTermTaxonomyTableName()} SET count = count + {$by} WHERE term_taxonomy_id = {$termTaxonomyId}"; @@ -1473,7 +1481,7 @@ public function dontHaveLinkInDatabase(array $criteria): void * * @param array $criteria An associative array of the column names and values to use as deletion * criteria. - * @param string $table The table name. + * @param string $table The table name. */ public function dontHaveInDatabase(string $table, array $criteria): void { @@ -1552,9 +1560,9 @@ public function dontHaveUserMetaInDatabase(array $criteria): void * $I->grabUserMetaFromDatabase($userId, 'api_data'); * ``` * - * @param int $userId The ID of th user to get the meta for. + * @param int $userId The ID of th user to get the meta for. * @param string $meta_key The meta key to fetch the value for. - * @param bool $single Whether to return a single value or an array of values. + * @param bool $single Whether to return a single value or an array of values. * * @return array|mixed An array of the different meta key values or a single value if `$single` is set * to `true`. @@ -1581,7 +1589,7 @@ public function grabUserMetaFromDatabase(int $userId, string $meta_key, bool $si return $value; } - $normalized[] =$value; + $normalized[] = $value; } return $normalized; @@ -1596,8 +1604,8 @@ public function grabUserMetaFromDatabase(int $userId, string $meta_key, bool $si * $I->grabAllFromDatabase($books, 'title', ['genre' => 'fiction']); * ``` * - * @param string $table The table to grab the values from. - * @param string $column The column to fetch. + * @param string $table The table to grab the values from. + * @param string $column The column to fetch. * @param array $criteria The search criteria. * * @return array> An array of results. @@ -1625,7 +1633,7 @@ public function grabAllFromDatabase(string $table, string $column, array $criter * ``` * * @param string $transient The transient name. - * @param mixed $value The transient value. + * @param mixed $value The transient value. * * @return int The inserted option `option_id`. */ @@ -1647,7 +1655,7 @@ public function haveTransientInDatabase(string $transient, mixed $value): int * * @param string $option_name The option name. * @param mixed $option_value The option value; if an array or object it will be serialized. - * @param string $autoload Whether the option should be autoloaded by WordPress or not. + * @param string $autoload Whether the option should be autoloaded by WordPress or not. * * @return int The inserted option `option_id` */ @@ -1691,7 +1699,7 @@ public function dontHaveTransientInDatabase(string $transient): void * $I->dontHaveOptionInDatabase('bar', 'baz'); * ``` * - * @param string $key The option name. + * @param string $key The option name. * @param mixed|null $value If set the option will only be removed if its value matches the passed one. */ public function dontHaveOptionInDatabase(string $key, mixed $value = null): void @@ -1715,7 +1723,7 @@ public function dontHaveOptionInDatabase(string $key, mixed $value = null): void * $fooCountOptionId = $I->haveSiteOptionInDatabase('foo_count','23'); * ``` * - * @param string $key The name of the option to insert. + * @param string $key The name of the option to insert. * @param mixed $value The value to insert for the option. * * @return int The inserted option `option_id`. @@ -1756,8 +1764,6 @@ public function useMainBlog(): void * * This has nothing to do with WordPress `switch_to_blog` function, this code will affect the table prefixes used. * - * @param int $blogId The ID of the blog to use. - * @throws ModuleException If the blog ID is not an integer greater than or equal to 0. * @example * ```php * // Switch to the blog with ID 23. @@ -1767,6 +1773,8 @@ public function useMainBlog(): void * // Switch to the main blog using this method. * $I->useBlog(1); * ``` + * @param int $blogId The ID of the blog to use. + * @throws ModuleException If the blog ID is not an integer greater than or equal to 0. */ public function useBlog(int $blogId = 1): void { @@ -1787,11 +1795,6 @@ public function useBlog(int $blogId = 1): void /** * Gets the blog URL from the Blog ID. * - * @param int $blogId The ID of the blog to get the URL for. - * - * @return string The blog URL. - * @throws ModuleException If the blog ID is not found in the database. - * * @example * ```php * // Get the URL for the main blog. @@ -1799,6 +1802,11 @@ public function useBlog(int $blogId = 1): void * // Get the URL for the blog with ID 23. * $blog23Url = $I->grabBlogUrl(23); * ``` + * @param int $blogId The ID of the blog to get the URL for. + * + * @return string The blog URL. + * @throws ModuleException If the blog ID is not found in the database. + * */ public function grabBlogUrl(int $blogId = 1): string { @@ -1847,7 +1855,7 @@ public function grabBlogUrl(int $blogId = 1): string * $I->dontHaveSiteOptionInDatabase('foo_count', 23); * ``` * - * @param string $key The option name. + * @param string $key The option name. * @param mixed|null $value If set the option will only be removed it its value matches the specified one. */ public function dontHaveSiteOptionInDatabase(string $key, mixed $value = null): void @@ -1869,7 +1877,7 @@ public function dontHaveSiteOptionInDatabase(string $key, mixed $value = null): * $I->haveSiteTransientInDatabase('api_data', ['user' => 'luca', 'token' => '11ae3ijns-j83']); * ``` * - * @param string $key The key of the site transient to insert, w/o the `_site_transient_` prefix. + * @param string $key The key of the site transient to insert, w/o the `_site_transient_` prefix. * @param mixed $value The value to insert; if serializable the value will be serialized. * * @return int The inserted transient `option_id` @@ -1978,7 +1986,7 @@ public function grabSiteTransientFromDatabase(string $key): mixed * $I->seeSiteSiteTransientInDatabase('total_counts', 23); * ``` * - * @param string $key The name of the transient to check for, w/o the `_site_transient_` prefix. + * @param string $key The name of the transient to check for, w/o the `_site_transient_` prefix. * @param mixed|null $value If provided then the assertion will include the value. * * @throws JsonException @@ -2009,7 +2017,7 @@ public function seeSiteSiteTransientInDatabase(string $key, mixed $value = null) * ``` * * @param array|string $criteriaOrName An array of search criteria or the option name. - * @param mixed|null $value The optional value to try and match, only used if the option + * @param mixed|null $value The optional value to try and match, only used if the option * name is provided. * * @@ -2035,7 +2043,7 @@ public function seeOptionInDatabase(array|string $criteriaOrName, mixed $value = * ``` * * @param array|string $criteriaOrName An array of search criteria or the option name. - * @param mixed|null $value The optional value to try and match, only used if the option + * @param mixed|null $value The optional value to try and match, only used if the option * name is provided. * * @@ -2071,7 +2079,7 @@ public function seeSiteOptionInDatabase(array|string $criteriaOrName, mixed $val * `Post Title - 1` for the second one and so on. * The same applies to meta values as well. * - * @param int $count The number of posts to insert. + * @param int $count The number of posts to insert. * * @return array An array of the inserted post IDs. * @@ -2119,7 +2127,7 @@ protected function setTemplateData(array $overrides = []): array * Replaces each occurrence of the `{{n}}` placeholder with the specified number. * * @param string|array $input The entry, or entries, to replace the placeholder in. - * @param int $i The value to replace the placeholder with. + * @param int $i The value to replace the placeholder with. * * @return array The input array with any `{{n}}` placeholder replaced with a number. */ @@ -2142,7 +2150,7 @@ protected function replaceNumbersInArray(string|array $input, int $i): array * Replaces the `{{n}}` placeholder with the specified number. * * @param string $template The string to replace the placeholder in. - * @param int $i The value to replace the placeholder with. + * @param int $i The value to replace the placeholder with. * * @return string The string with replaces placeholders. */ @@ -2193,7 +2201,7 @@ public function seeTermInDatabase(array $criteria): void * ``` * * @param array $criteria An array of search criteria. - * @param bool $purgeMeta Whether the terms meta should be purged along side with the meta or not. + * @param bool $purgeMeta Whether the terms meta should be purged along side with the meta or not. * * @throws Exception If there's an issue removing the rows. */ @@ -2295,8 +2303,8 @@ public function dontSeeTermInDatabase(array $criteria): void * $I->haveManyCommentsInDatabase(3, $postId, ['comment_content' => 'Comment {{n}}']); * ``` * - * @param int $count The number of comments to insert. - * @param int $comment_post_ID The comment parent post ID. + * @param int $count The number of comments to insert. + * @param int $comment_post_ID The comment parent post ID. * @param array $overrides An associative array to override the defaults. * * @return array An array containing the inserted comments IDs. @@ -2321,7 +2329,7 @@ public function haveManyCommentsInDatabase(int $count, int $comment_post_ID, arr * $I->haveCommentInDatabase($postId, ['comment_content' => 'Test Comment', 'comment_karma' => 23]); * ``` * - * @param int $comment_post_ID The id of the post the comment refers to. + * @param int $comment_post_ID The id of the post the comment refers to. * @param array $data The comment data overriding default and random generated values. * * @return int The inserted comment `comment_id`. @@ -2385,8 +2393,8 @@ public function haveCommentInDatabase(int $comment_post_ID, array $data = []): i * $I->haveCommentMetaInDatabase($commentId, 'api_data', $apiData); * ``` * - * @param int $comment_id The ID of the comment to insert the meta for. - * @param string $meta_key The key of the comment meta to insert. + * @param int $comment_id The ID of the comment to insert the meta for. + * @param string $meta_key The key of the comment meta to insert. * @param mixed $meta_value The value of the meta to insert, if serializable it will be serialized. * * @return int The inserted comment meta ID. @@ -2429,7 +2437,7 @@ public function grabCommentmetaTableName(): string * $draftsCount = $I->countRowsInDatabase($postsTable, ['post_status' => 'draft']); * ``` * - * @param string $table The table to count the rows in. + * @param string $table The table to count the rows in. * @param array $criteria Search criteria, if empty all table rows will be counted. * * @return int The number of table rows matching the search criteria. @@ -2448,7 +2456,7 @@ public function countRowsInDatabase(string $table, array $criteria = []): int * ``` * * @param array $criteria An array of search criteria. - * @param bool $purgeMeta If set to `true` then the meta for the comment will be purged too. + * @param bool $purgeMeta If set to `true` then the meta for the comment will be purged too. * * * @throws Exception In case of incoherent query criteria. @@ -2521,7 +2529,7 @@ public function dontHaveCommentMetaInDatabase(array $criteria): void * $linkIds = $I->haveManyLinksInDatabase(3, ['link_url' => 'http://example.org/test-{{n}}']); * ``` * - * @param int $count The number of links to insert. + * @param int $count The number of links to insert. * @param array $overrides Overrides for the default arguments. * * @return array An array of inserted `link_id`s. @@ -2592,9 +2600,9 @@ public function grabLinksTableName(): string * ); * ``` * - * @param int $count The number of users to insert. - * @param string $user_login The user login name. - * @param string $role The user role. + * @param int $count The number of users to insert. + * @param string $user_login The user login name. + * @param string $role The user role. * @param array $overrides An array of values to override the default ones. * * @return array An array of user IDs. @@ -2655,14 +2663,14 @@ public function haveManyUsersInDatabase( * $userId = $I->haveUserInDatabase('luca', ''); * ``` * - * @param string|array $role The user role slug(s), e.g. `administrator` or `['author', 'editor']`; + * @param string|array $role The user role slug(s), e.g. `administrator` or `['author', 'editor']`; * defaults to `subscriber`. If more than one role is specified, then the * first role in the list will be the user primary role and the * `wp_user_level` will be set to that role. * @param array $overrides An associative array of column names and values overriding defaults * in the `users` and `usermeta` table. * - * @param string $user_login The user login name. + * @param string $user_login The user login name. * * @return int The inserted user ID. * @@ -2775,7 +2783,7 @@ public function grabUsersTableName(): string * ); * ``` * - * @param int $userId The ID of the user to set the capabilities of. + * @param int $userId The ID of the user to set the capabilities of. * @param string|array|array> $role Either a role string (e.g. * `administrator`),an associative array of blog * IDs/roles for a multisite installation (e.g. `[1 @@ -2809,9 +2817,9 @@ public function haveUserCapabilitiesInDatabase(int $userId, string|array $role): * $I->haveUserMetaInDatabase($userId, 'karma', 23); * ``` * - * @param int $userId The user ID. - * @param string $meta_key The meta key to set the value for. - * @param mixed $meta_value Either a single value or an array of values; objects will be serialized while array of + * @param int $userId The user ID. + * @param string $meta_key The meta key to set the value for. + * @param mixed $meta_value Either a single value or an array of values; objects will be serialized while array of * values will trigger the insertion of multiple rows. * * @return array An array of inserted `umeta_id`s. @@ -2862,7 +2870,7 @@ public function grabUsermetaTableName(): string * $I->haveUserLevelsInDatabase($userId, $moreThanAnEditorLessThanAnAdmin); * ``` * - * @param int $userId The ID of the user to set the + * @param int $userId The ID of the user to set the * level for. * @param string|array|array|array> $role Either a user role (e.g. * `editor`), a list of user @@ -2907,9 +2915,9 @@ public function haveUserLevelsInDatabase(int $userId, array|string $role): array * $termTaxonomyIds = array_column($terms, 1); * ``` * - * @param int $count The number of terms to insert. - * @param string $name The term name template, can include the `{{n}}` placeholder. - * @param string $taxonomy The taxonomy to insert the terms for. + * @param int $count The number of terms to insert. + * @param string $name The term name template, can include the `{{n}}` placeholder. + * @param string $taxonomy The taxonomy to insert the terms for. * @param array $overrides An associative array of default overrides. * * @return array> An array of arrays containing `term_id` and `term_taxonomy_id` of the inserted terms. @@ -3218,11 +3226,11 @@ protected function prepareBlogCriteria(array $criteria): array * } * ``` * - * @param int $count The number of blogs to create. + * @param int $count The number of blogs to create. * * @param array $overrides An array of values to override the default ones; `{{n}}` will be replaced * by the count. - * @param bool $subdomain Whether the new blogs should be created as a subdomain or subfolder. + * @param bool $subdomain Whether the new blogs should be created as a subdomain or subfolder. * * @return array An array of inserted blogs `blog_id`s. * @throws JsonException @@ -3258,9 +3266,9 @@ public function haveManyBlogsInDatabase(int $count, array $overrides = [], bool * $blogId = $I->haveBlogInDatabase('test', ['administrator' => $userId], false); * ``` * - * @param string $domainOrPath The subdomain or the path to the be used for the blog. + * @param string $domainOrPath The subdomain or the path to the be used for the blog. * @param array $overrides An array of values to override the defaults. - * @param bool $subdomain Whether the new blog should be created as a subdomain (`true`) + * @param bool $subdomain Whether the new blog should be created as a subdomain (`true`) * or subfolder (`true`) * * @return int The inserted blog `blog_id`. @@ -3336,9 +3344,9 @@ public function getSiteDomain(): string /** * Scaffolds the blog tables to support and create a blog. * - * @param int $blogId The blog ID. + * @param int $blogId The blog ID. * @param string $domainOrPath Either the path or the sub-domain of the blog to create. - * @param bool $isSubdomain Whether to create a sub-folder or a sub-domain blog. + * @param bool $isSubdomain Whether to create a sub-folder or a sub-domain blog. * * @throws PDOException If there's any issue executing the query. * @throws JsonException If there's any issue debugging the query. @@ -3421,8 +3429,8 @@ protected function getWpFilesystemModule(): WPFilesystem * ``` * * @param array $criteria An array of search criteria to find the blog rows in the blogs table. - * @param bool $removeTables Remove the blog tables. - * @param bool $removeUploads Remove the blog uploads; requires the `WPFilesystem` module. + * @param bool $removeTables Remove the blog tables. + * @param bool $removeUploads Remove the blog uploads; requires the `WPFilesystem` module. * * @throws JsonException If there's any issue debugging the query. */ @@ -3434,8 +3442,10 @@ public function dontHaveBlogInDatabase(array $criteria, bool $removeTables = tru foreach (array_column($blogIds, 'blog_id') as $blogId) { if (empty($blogId) || !is_numeric($blogId)) { - $this->debug(message: 'No blog found matching criteria ' . - json_encode($criteria, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT)); + $this->debug( + message: 'No blog found matching criteria ' . + json_encode($criteria, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT) + ); return; } @@ -3552,10 +3562,10 @@ public function dontSeeBlogInDatabase(array $criteria): void * $I->useTheme('acme', 'acme', 'Acme Theme'); * ``` * - * @param string $stylesheet The theme stylesheet slug, e.g. `twentysixteen`. - * @param string|null $template The theme template slug, e.g. `twentysixteen`, defaults to `$stylesheet`. + * @param string $stylesheet The theme stylesheet slug, e.g. `twentysixteen`. + * @param string|null $template The theme template slug, e.g. `twentysixteen`, defaults to `$stylesheet`. * - * @param string|null $themeName The theme name, e.g. `Acme`, defaults to the "title" version of + * @param string|null $themeName The theme name, e.g. `Acme`, defaults to the "title" version of * `$stylesheet`. */ public function useTheme(string $stylesheet, string $template = null, string $themeName = null): void @@ -3579,8 +3589,8 @@ public function useTheme(string $stylesheet, string $template = null, string $th * list($termId, $termTaxId) = $I->haveMenuInDatabase('test', 'sidebar'); * ``` * - * @param string $slug The menu slug. - * @param string $location The theme menu location the menu will be assigned to. + * @param string $slug The menu slug. + * @param string $location The theme menu location the menu will be assigned to. * @param array $overrides An array of values to override the defaults. * * @return array An array containing the created menu `term_id` and `term_taxonomy_id`. @@ -3626,11 +3636,11 @@ public function haveMenuInDatabase(string $slug, string $location, array $overri * $I->haveMenuItemInDatabase('test', 'Test two', 1); * ``` * - * @param string $title The menu item title. - * @param int|null $menuOrder An optional menu order, `1` based. - * @param array $meta An associative array that will be prefixed with `_menu_item_` for the item + * @param string $title The menu item title. + * @param int|null $menuOrder An optional menu order, `1` based. + * @param array $meta An associative array that will be prefixed with `_menu_item_` for the item * post meta. - * @param string $menuSlug The menu slug the item should be added to. + * @param string $menuSlug The menu slug the item should be added to. * * @return int The menu item post `ID` * @throws ModuleException If there's an issue inserting the database row. @@ -3689,7 +3699,7 @@ public function seeTermRelationshipInDatabase(array $criteria): void /** * Sets the database driver of this object. * - * @param Driver $driver A reference to the database driver being set. + * @param Driver $driver A reference to the database driver being set. * @param string $forDatabase The database key to set the * database driver for. */ @@ -3712,15 +3722,15 @@ public function _setDriver(Driver $driver, string $forDatabase = 'default'): voi * * Requires the WPFilesystem module. * - * @param string|int $date Either a string supported by the `strtotime` function or a UNIX + * @param string|int $date Either a string supported by the `strtotime` function or a UNIX * timestamp that should be used to build the "year/time" uploads * sub-folder structure. - * @param array $overrides An associative array of values overriding the default ones. + * @param array $overrides An associative array of values overriding the default ones. * @param array>|null $imageSizes An associative array in the format [ => * [,]] to override the image sizes created by * default. * - * @param string $file The absolute path to the attachment file. + * @param string $file The absolute path to the attachment file. * * @return int The post ID of the inserted attachment. * @@ -3951,12 +3961,12 @@ public function dontSeeAttachmentInDatabase(array $criteria): void * $I->dontHaveAttachmentInDatabase($thumbnailId, true, true); * ``` * - * @param bool $purgeMeta If set to `true` then the meta for the attachment will be purged too. - * @param bool $removeFiles Remove all files too, requires the `WPFilesystem` module to be loaded in + * @param bool $purgeMeta If set to `true` then the meta for the attachment will be purged too. + * @param bool $removeFiles Remove all files too, requires the `WPFilesystem` module to be loaded in * the suite. * * - * @param array $criteria An array of search criteria to find the attachment post in the posts + * @param array $criteria An array of search criteria to find the attachment post in the posts * table. * * @throws ModuleRequireException If the WPFilesystem module is not loaded in the suite and the `$removeFiles` @@ -4096,7 +4106,7 @@ public function grabAttachmentMetadata(int $attachmentPostId): array * ``` * * @param array $criteria An array of search criteria. - * @param bool $purgeMeta If set to `true` then the meta for the post will be purged too. + * @param bool $purgeMeta If set to `true` then the meta for the post will be purged too. */ public function dontHavePostInDatabase(array $criteria, bool $purgeMeta = true): void { @@ -4138,7 +4148,7 @@ public function dontHavePostMetaInDatabase(array $criteria): void * ``` * * @param string $userEmail The email of the user to remove. - * @param bool $purgeMeta Whether the user meta should be purged alongside the user or not. + * @param bool $purgeMeta Whether the user meta should be purged alongside the user or not. * * @return array An array of the deleted user(s) ID(s) * @@ -4199,7 +4209,7 @@ public function grabTablePrefix(): string * ``` * * @param int|string $userIdOrLogin The user ID or login name. - * @param bool $purgeMeta Whether the user meta should be purged alongside the user or not. + * @param bool $purgeMeta Whether the user meta should be purged alongside the user or not. */ public function dontHaveUserInDatabase(int|string $userIdOrLogin, bool $purgeMeta = true): void { @@ -4253,9 +4263,9 @@ public function grabUserIdFromDatabase(string $userLogin): int|false * $thumbnail_id = $I->grabPostMetaFromDatabase($postId, '_thumbnail_id', true); * ``` * - * @param int $postId The post ID. + * @param int $postId The post ID. * @param string $metaKey The key of the meta to retrieve. - * @param bool $single Whether to return a single meta value or an array of all available meta values. + * @param bool $single Whether to return a single meta value or an array of all available meta values. * * @return mixed|array Either a single meta value or an array of all the available meta values. */ @@ -4284,7 +4294,7 @@ public function grabPostMetaFromDatabase(int $postId, string $metaKey, bool $sin * $blogOptionTable = $I->grabBlogTableName($blogId, 'option'); * ``` * - * @param int $blogId The blog ID. + * @param int $blogId The blog ID. * @param string $table The table name, without table prefix. * * @return string The full blog table name, including the table prefix or an empty string @@ -4474,7 +4484,7 @@ protected function loadDumpUsingDriver(string $databaseKey): void /** * Loads the SQL dumps specified for a database. * - * @param string|null $databaseKey The key of the database to load. + * @param string|null $databaseKey The key of the database to load. * @param array|null $databaseConfig The configuration for the database to load. */ public function _loadDump(string $databaseKey = null, array $databaseConfig = null): void @@ -4496,14 +4506,14 @@ public function _loadDump(string $databaseKey = null, array $databaseConfig = nu * $I->dontSeePostWithTermInDatabase($postId, $nonFiction['term_taxonomy_id], ); * ``` * - * @param int $post_id The post ID. - * @param int $term_taxonomy_id The term `term_id` or `term_taxonomy_id`; if the `$taxonomy` argument is + * @param int $post_id The post ID. + * @param int $term_taxonomy_id The term `term_id` or `term_taxonomy_id`; if the `$taxonomy` argument is * passed this parameter will be interpreted as a `term_id`, else as a * `term_taxonomy_id`. - * @param int|null $term_order The order the term applies to the post, defaults to `null` to not use + * @param int|null $term_order The order the term applies to the post, defaults to `null` to not use * the * term order. - * @param string|null $taxonomy The taxonomy the `term_id` is for; if passed this parameter will be used + * @param string|null $taxonomy The taxonomy the `term_id` is for; if passed this parameter will be used * to build a `taxonomy_term_id` from the `term_id`. * * @@ -4633,7 +4643,7 @@ protected function prepareDb(): void /** * Dispatches an event after the database has been prepared. * - * @param WPDb $origin This objects. + * @param WPDb $origin This objects. * @param array $config The current WPDb module configuration. */ Dispatcher::dispatch(static::EVENT_AFTER_DB_PREPARE, $this, $this->config); @@ -4649,7 +4659,7 @@ protected function prepareDb(): void * $I->havePostThumbnailInDatabase($postId, $attachmentId); * ``` * - * @param int $postId The post ID to assign the thumbnail (featured image) to. + * @param int $postId The post ID to assign the thumbnail (featured image) to. * @param int $thumbnailId The post ID of the attachment. * * @return int The inserted meta id. @@ -4708,7 +4718,7 @@ public function importSql(array $sql): void /** * Normalizes a site option name. * - * @param string $name The site option name to normalize, either containing a `_site_option_` prefix or not. + * @param string $name The site option name to normalize, either containing a `_site_option_` prefix or not. * @param string $prefix The option name prefix to normalize for. * * @return string The normalized site option name, with a `_site_option_` prefix. @@ -4731,7 +4741,7 @@ protected function normalizePrefixedOptionName(string $name, string $prefix): st * ``` * * @param array|string $criteriaOrName An array of search criteria or the option name. - * @param mixed|null $value The optional value to try and match, only used if the option + * @param mixed|null $value The optional value to try and match, only used if the option * name is provided. * * @@ -4753,7 +4763,7 @@ public function dontSeeSiteOptionInDatabase(array|string $criteriaOrName, mixed * * @param string|array $criteriaOrName Either the ready to use array criteria or the site option * name. - * @param mixed|null $value The site option value, only used if the first parameter is not + * @param mixed|null $value The site option value, only used if the first parameter is not * an array. * * @return array An array of criteria to search for the site option. @@ -4773,7 +4783,7 @@ protected function buildSiteOptionCriteria(string|array $criteriaOrName, mixed $ * Normalizes an option criteria to consistently build array format criteria from name and value tuples. * * @param array|string $criteriaOrName The option name or the criteria to check the option by. - * @param mixed|null $value The option value to check; ignored if the first parameter is + * @param mixed|null $value The option value to check; ignored if the first parameter is * an array. * * @return array An array of option criteria, normalized. @@ -4931,4 +4941,50 @@ private function reconnectCurrentDatabase(): void // Do nothing, the attempt was not successful. } } + + /** + * @throws ModuleException + */ + public function _getDbh(): PDO + { + $dbh = parent::_getDbh(); + + if ($dbh->getAttribute(PDO::ATTR_DRIVER_NAME) !== 'mysql') { + return $dbh; + } + + if ($this->modesSetter === null) { + $stmt = $dbh->query('SELECT @@SESSION.sql_mode', PDO::FETCH_NUM); + + if ($stmt === false) { + throw new ModuleException($this, 'Could not get the current SQL mode.'); + } + + $currentModes = $stmt->fetchColumn(); + $currentModes = explode(',', (string)$currentModes); + $incompatibleModes = [ + 'NO_ZERO_DATE', + 'ONLY_FULL_GROUP_BY', + 'STRICT_TRANS_TABLES', + 'STRICT_ALL_TABLES', + 'TRADITIONAL', + 'ANSI', + ]; + $safeModes = array_diff($currentModes, $incompatibleModes); + + if (empty($safeModes)) { + $this->modesSetter = static function ($dbh) { + return $dbh->exec("SET SESSION sql_mode=''"); + }; + } else { + $this->modesSetter = static function ($dbh) use ($safeModes) { + return $dbh->exec("SET SESSION sql_mode='" . implode(',', $safeModes) . "'"); + }; + } + } + + ($this->modesSetter)($dbh); + + return $dbh; + } } diff --git a/tests/unit/lucatume/WPBrowser/Module/WPDbTest.php b/tests/unit/lucatume/WPBrowser/Module/WPDbTest.php index 8dc674a0a..6797e5b0b 100644 --- a/tests/unit/lucatume/WPBrowser/Module/WPDbTest.php +++ b/tests/unit/lucatume/WPBrowser/Module/WPDbTest.php @@ -13,6 +13,8 @@ use lucatume\WPBrowser\Utils\Env; use lucatume\WPBrowser\Utils\Filesystem as FS; use PDO; +use PDOStatement; +use PHPUnit\Framework\AssertionFailedError; use RuntimeException; class WPDbTest extends Unit @@ -202,17 +204,24 @@ public function should_not_try_to_replace_the_site_url_in_the_dump_if_url_replac $sut->_initialize(); $sut->_beforeSuite(); - $this->assertEquals('https://some-other-site.dev', - $sut->grabFromDatabase('wp_options', 'option_value', ['option_name' => 'siteurl'])); - $this->assertEquals('https://some-other-site.dev/home', - $sut->grabFromDatabase('wp_options', 'option_value', ['option_name' => 'home'])); - $this->assertEquals('https://some-wp.dev', + $this->assertEquals( + 'https://some-other-site.dev', + $sut->grabFromDatabase('wp_options', 'option_value', ['option_name' => 'siteurl']) + ); + $this->assertEquals( + 'https://some-other-site.dev/home', + $sut->grabFromDatabase('wp_options', 'option_value', ['option_name' => 'home']) + ); + $this->assertEquals( + 'https://some-wp.dev', self::$pdo->query("SELECT url FROM test_urls WHERE id = 1")->fetchColumn() ); - $this->assertEquals('https://some-other-site.dev', + $this->assertEquals( + 'https://some-other-site.dev', self::$pdo->query("SELECT url FROM test_urls WHERE id = 2")->fetchColumn() ); - $this->assertEquals('https://localhost:8080', + $this->assertEquals( + 'https://localhost:8080', self::$pdo->query("SELECT url FROM test_urls WHERE id = 3")->fetchColumn() ); } @@ -249,7 +258,6 @@ public function should_throw_throw_if_db_url_not_set_and_credentials_are_missing $this->expectException(ModuleConfigException::class); $wpdb = new WPDb(new ModuleContainer(new Di, []), $config); - } /** @@ -487,4 +495,136 @@ public function should_support_custom_post_operations_with_sqlite(): void $this->assertEquals('Alice in Wonderland', $wpdb->grabPostFieldFromDatabase($postID, 'post_title')); $this->assertEquals('book', $wpdb->grabPostFieldFromDatabase($postID, 'post_type')); } + + public function modesProvider(): array + { + return [ + 'mixed bags' => [ + [ + 'SOME_MODE_1', + 'NO_ZERO_DATE', + 'ONLY_FULL_GROUP_BY', + 'SOME_MODE_2', + 'STRICT_TRANS_TABLES', + 'STRICT_ALL_TABLES', + 'TRADITIONAL', + 'ANSI', + 'SOME_MODE_3' + ], + "'SOME_MODE_1,SOME_MODE_2,SOME_MODE_3'" + ], + 'empty' => [ + [], + "''" + ], + 'only incompatible modes' => [ + [ + 'NO_ZERO_DATE', + 'ONLY_FULL_GROUP_BY', + 'STRICT_TRANS_TABLES', + 'STRICT_ALL_TABLES', + 'TRADITIONAL', + 'ANSI', + ], + "''" + ], + 'only some compatible modes' => [ + [ + 'NO_ZERO_DATE', + 'ONLY_FULL_GROUP_BY', + 'STRICT_TRANS_TABLES', + ], + "''" + ], + 'only compatible modes' => [ + [ + 'SOME_MODE_1', + 'SOME_MODE_2', + 'SOME_MODE_3', + ], + "'SOME_MODE_1,SOME_MODE_2,SOME_MODE_3'" + ], + ]; + } + + /** + * @dataProvider modesProvider + */ + public function testGetDbhSetsModesOnDbh(array $currentModes, string $expectedModes): void + { + $config = [ + 'url' => 'http://example.com', + 'dbUrl' => 'mysql://User:Pa55word@localhost:3306/test' + ]; + $setModes = null; + $mockSelectModesStatement = $this->makeEmpty(PDOStatement::class, [ + 'fetchColumn' => function () use ($currentModes) { + return implode(',', $currentModes); + } + ]); + $mockPdo = $this->makeEmpty(PDO::class, [ + 'getAttribute' => function (int $mode) { + if ($mode === PDO::ATTR_DRIVER_NAME) { + return 'mysql'; + } + + throw new AssertionFailedError("Unexpected call to PDO::getAttribute with mode $mode"); + }, + 'query' => function (string $query) use ($mockSelectModesStatement) { + if ($query === 'SELECT @@SESSION.sql_mode') { + return $mockSelectModesStatement; + } + + throw new AssertionFailedError("Unexpected call to PDO::query: $query"); + }, + 'exec' => function ($query) use (&$setModes) { + if (str_starts_with($query, 'SET SESSION sql_mode=')) { + $setModes = str_replace('SET SESSION sql_mode=', '', $query); + return 1; + } + + throw new AssertionFailedError("Unexpected call to PDO::exec: $query"); + } + ]); + $this->setClassMock(PDO::class, $mockPdo); + + $wpdb = new WPDb(new ModuleContainer(new Di, []), $config); + $wpdb->_initialize(); + $dbh = $wpdb->_getDbh(); + + $this->assertSame($mockPdo, $dbh); + $this->assertEquals($expectedModes, $setModes); + } + + public function testGetDbhWillNotSetModesOnSqlite(): void + { + $config = [ + 'url' => 'http://example.com', + 'dbUrl' => 'sqlite:///path/to/db.sqlite', + 'urlReplacement' => false, + ]; + $queries = []; + $mockPdo = $this->makeEmpty(PDO::class, [ + 'getAttribute' => function (int $mode) { + if ($mode === PDO::ATTR_DRIVER_NAME) { + return 'sqlite'; + } + + throw new AssertionFailedError("Unexpected call to PDO::getAttribute with mode $mode"); + }, + 'query' => function (string $query) use (&$queries) { + $queries[] = $query; + }, + 'exec' => function ($query) use(&$queries) { + $queries[] = $query; + } + ]); + $this->setClassMock(PDO::class, $mockPdo); + + $wpdb = new WPDb(new ModuleContainer(new Di, []), $config); + $wpdb->_initialize(); + $dbh = $wpdb->_getDbh(); + + $this->assertEmpty($queries); + } } From 570fad4fdefcb9c41a5e3fa160c8a6719067986b Mon Sep 17 00:00:00 2001 From: Luca Tumedei Date: Tue, 20 Aug 2024 09:19:40 +0200 Subject: [PATCH 27/30] refactor(Adapters/Process) remove second call to parent::__construct --- src/Adapters/Symfony/Component/Process/Process.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Adapters/Symfony/Component/Process/Process.php b/src/Adapters/Symfony/Component/Process/Process.php index e1110e6c4..56f024583 100644 --- a/src/Adapters/Symfony/Component/Process/Process.php +++ b/src/Adapters/Symfony/Component/Process/Process.php @@ -40,8 +40,6 @@ public function __construct( // @phpstan-ignore-next-line $this->inheritEnvironmentVariables(true); } - - parent::__construct($command, $cwd, $env, $input, $timeout); } public function getStartTime(): float From af8a3ca591bc450fa5907fe4fe2b45a5c9112728 Mon Sep 17 00:00:00 2001 From: Luca Tumedei Date: Tue, 20 Aug 2024 09:21:00 +0200 Subject: [PATCH 28/30] tests(LoopIsolation) use file payloads in trait Avoid the "arguments list too long" error on `proc_open` due to the large context to pass over in tests. --- src/Process/Loop.php | 9 ++++++++- tests/_support/Traits/LoopIsolation.php | 1 + 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/Process/Loop.php b/src/Process/Loop.php index 48a9f3d9f..edcd53c69 100644 --- a/src/Process/Loop.php +++ b/src/Process/Loop.php @@ -67,6 +67,7 @@ public function __construct( * rethrow?: bool, * requireFiles?: array, * cwd?: string, + * use_file_payloads?: bool, * } $options * * @throws ProcessException @@ -75,7 +76,13 @@ public function __construct( */ public static function executeClosure(Closure $closure, int $timeout = 30, array $options = []): Result { - $loop = (new self([$closure], 1, true, $timeout, $options))->run(); + $loop = new self([$closure], 1, true, $timeout, $options); + + if (!empty($options['use_file_payloads'])) { + $loop->setUseFilePayloads(true); + } + + $loop->run(); $results = $loop->getResults(); $result = $results[0]; $returnValue = $result->getReturnValue(); diff --git a/tests/_support/Traits/LoopIsolation.php b/tests/_support/Traits/LoopIsolation.php index 1bbcabad4..0f9ce0874 100644 --- a/tests/_support/Traits/LoopIsolation.php +++ b/tests/_support/Traits/LoopIsolation.php @@ -32,6 +32,7 @@ protected function assertInIsolation( } $options['cwd'] = !empty($options['cwd']) ? $options['cwd'] : getcwd(); + $options['use_file_payloads'] = true; $timeout = Debug::isEnabled() ? PHP_INT_MAX : 30; $result = Loop::executeClosure($runAssertions, $timeout, $options); From 01aa2d712518b05669a3cd9725872a0e38209e19 Mon Sep 17 00:00:00 2001 From: Luca Tumedei Date: Tue, 20 Aug 2024 17:46:05 +0200 Subject: [PATCH 29/30] fix(PreloadFilters) use actual tag --- src/WordPress/PreloadFilters.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/WordPress/PreloadFilters.php b/src/WordPress/PreloadFilters.php index 127628a65..51e7db9e3 100644 --- a/src/WordPress/PreloadFilters.php +++ b/src/WordPress/PreloadFilters.php @@ -32,7 +32,7 @@ public static function addFilter( if (!isset($wp_filter[$hookName])) { $wp_filter[$hookName] = []; } - if (!isset($wp_filter['string'][$priority])) { + if (!isset($wp_filter[$hookName][$priority])) { $wp_filter[$hookName][$priority] = []; } $wp_filter[$hookName][$priority][] = [ From bd0268db8bb7509243423e881d180fcc44b22be8 Mon Sep 17 00:00:00 2001 From: Luca Tumedei Date: Wed, 21 Aug 2024 07:48:12 +0200 Subject: [PATCH 30/30] fix(bin/setup-wp.php) use the correct mysql server directory --- bin/setup-wp.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/setup-wp.php b/bin/setup-wp.php index 9e8e4820c..c2f011e9b 100644 --- a/bin/setup-wp.php +++ b/bin/setup-wp.php @@ -23,7 +23,7 @@ $env = $dotenv->load(); $mysqlServer = new MysqlServer( - codecept_output_dir('_mysql-server'), + codecept_output_dir('_mysql_server'), $_ENV['WORDPRESS_DB_LOCALHOST_PORT'], $_ENV['WORDPRESS_DB_NAME'], $_ENV['WORDPRESS_DB_USER'],