From c2f29a2a85d005ad13026f49fd7de28bcaab24fd Mon Sep 17 00:00:00 2001 From: Luca Tumedei Date: Sun, 1 Sep 2024 11:19:03 +0200 Subject: [PATCH] build(v35) transpile from v4 --- CHANGELOG.md | 11 + .../includes/abstract-testcase.php | 35 +- includes/core-phpunit/includes/install.php | 11 +- .../testcase-rest-post-type-controller.php | 2 + .../includes/unregister-blocks-hooks.php | 1 + .../Symfony/Component/Process/Process.php | 2 - src/Extension/ChromeDriverController.php | 3 + src/Extension/MysqlServerController.php | 237 ++++ src/ManagedProcess/ChromeDriver.php | 4 +- src/ManagedProcess/ManagedProcessTrait.php | 3 +- src/ManagedProcess/MysqlServer.php | 606 ++++++++++ src/ManagedProcess/PhpBuiltInServer.php | 2 +- src/Module/WPDb.php | 291 +++-- src/Module/WPLoader.php | 120 +- src/Process/Loop.php | 9 +- src/TestCase/WPTestCase.php | 154 ++- .../WPTestCasePHPUnitMethodsTrait.php | 4 + src/Traits/UopzFunctions.php | 84 +- src/Utils/Filesystem.php | 54 + src/Utils/MachineInformation.php | 71 ++ src/Utils/Property.php | 17 +- src/WordPress/LoadSandbox.php | 3 +- src/WordPress/PreloadFilters.php | 2 +- ...rolTestCaseOverridingTestCasePHPUnit10.php | 33 + ...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 + tests/_support/Fork.php | 224 ++++ tests/_support/StubClassFactory.php | 143 ++- tests/_support/Traits/InstallationMocks.php | 103 ++ tests/_support/Traits/LoopIsolation.php | 11 +- tests/acceptance.suite.dist.yml | 3 +- .../lucatume/WPBrowser/Command/RunAllTest.php | 2 + .../Extension/DockerComposeControllerTest.php | 331 +++-- .../Extension/MysqlServerControllerTest.php | 633 ++++++++++ .../WPBrowser/Extension/SymlinkerTest.php | 2 + .../ManagedProcess/ChromedriverTest.php | 4 +- .../ManagedProcess/MysqlServerTest.php | 1077 +++++++++++++++++ .../lucatume/WPBrowser/Module/WPDbTest.php | 156 ++- .../WPBrowser/Module/WPFilesystemTest.php | 22 +- .../WPLoaderArbitraryPluginLocationTest.php | 2 + .../WPLoaderArbitraryThemeLocationTest.php | 2 + .../WPBrowser/Module/WPLoaderLoadOnlyTest.php | 82 ++ ...ffoldedInstallationCustomLocationsTest.php | 414 +++++++ .../WPBrowser/Module/WPLoaderTest.php | 277 ++++- .../WPBrowser/Module/WPTestCaseStrictTest.php | 2 + .../WPBrowser/Traits/UopzFunctionsTest.php | 319 +++++ .../unit/lucatume/WPBrowser/Utils/ArrTest.php | 108 +- .../Utils/ChromedriverInstallerTest.php | 3 + .../WPBrowser/Utils/FilesystemTest.php | 21 + .../Utils/MachineInformationTest.php | 101 ++ tests/webdriver/PluginActivationCest.php | 16 +- tests/wploadersuite/AttachmentCleanupTest.php | 32 + 56 files changed, 5389 insertions(+), 469 deletions(-) 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/files/BackupControlTestCaseOverridingTestCasePHPUnit10.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/_support/Fork.php create mode 100644 tests/_support/Traits/InstallationMocks.php 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/Module/WPLoaderLoadOnlyTest.php create mode 100644 tests/unit/lucatume/WPBrowser/Module/WPLoaderScaffoldedInstallationCustomLocationsTest.php create mode 100644 tests/unit/lucatume/WPBrowser/Utils/MachineInformationTest.php create mode 100644 tests/wploadersuite/AttachmentCleanupTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 4caae37fe..1e4fe6a13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ This project adheres to [Semantic Versioning](http://semver.org/). ## [unreleased] Unreleased +## Added + +- The `MysqlServerController` extension. + +### Fixed + +- Ensure the `WPLoader` module will initialize correctly when used in `loadOnly` mode not using the `EventDispatcherBridge` extension. (thanks @lxbdr) +- Support loading the `wpdb` class from either the `class-wpdb.php` file or the `wp-db.php` one, supporting older versions of WordPress (thanks @BrianHenryIE) +- Read content, plugins and mu-plugins directories paths from the `WPLoader` configuration parameters correctly. +- In the `WPTestCase` class, remove attachments created during tests between tests. + ## [3.6.5] 2024-06-26; ### Changed diff --git a/includes/core-phpunit/includes/abstract-testcase.php b/includes/core-phpunit/includes/abstract-testcase.php index 8df15d98d..fa0bce58c 100644 --- a/includes/core-phpunit/includes/abstract-testcase.php +++ b/includes/core-phpunit/includes/abstract-testcase.php @@ -554,16 +554,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/includes/core-phpunit/includes/install.php b/includes/core-phpunit/includes/install.php index 8a595903c..dbb13b5f0 100644 --- a/includes/core-phpunit/includes/install.php +++ b/includes/core-phpunit/includes/install.php @@ -40,8 +40,17 @@ require_once ABSPATH . 'wp-settings.php'; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; -require_once ABSPATH . 'wp-includes/class-wpdb.php'; +/** + * File was renamed in WordPress 6.1. + * + * @see https://core.trac.wordpress.org/ticket/56268 + * @see https://github.com/WordPress/WordPress/commit/8484c7babb6b6ee951f83babea656a294157665d + */ +require_once file_exists( ABSPATH . 'wp-includes/class-wpdb.php' ) + ? ABSPATH . 'wp-includes/class-wpdb.php' + : ABSPATH . 'wp-includes/wp-db.php'; + // Override the PHPMailer. global $phpmailer; require_once __DIR__ . '/mock-mailer.php'; diff --git a/includes/core-phpunit/includes/testcase-rest-post-type-controller.php b/includes/core-phpunit/includes/testcase-rest-post-type-controller.php index 82be74b7d..553373bde 100644 --- a/includes/core-phpunit/includes/testcase-rest-post-type-controller.php +++ b/includes/core-phpunit/includes/testcase-rest-post-type-controller.php @@ -109,8 +109,10 @@ protected function check_post_data( $post, $data, $context, $links ) { // Check filtered values. if ( post_type_supports( $post->post_type, 'title' ) ) { add_filter( 'protected_title_format', array( $this, 'protected_title_format' ) ); + add_filter( 'private_title_format', array( $this, 'protected_title_format' ) ); $this->assertSame( get_the_title( $post->ID ), $data['title']['rendered'] ); remove_filter( 'protected_title_format', array( $this, 'protected_title_format' ) ); + remove_filter( 'private_title_format', array( $this, 'protected_title_format' ) ); if ( 'edit' === $context ) { $this->assertSame( $post->post_title, $data['title']['raw'] ); } else { 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' ); diff --git a/src/Adapters/Symfony/Component/Process/Process.php b/src/Adapters/Symfony/Component/Process/Process.php index 5ec966a1d..7884eb3b9 100644 --- a/src/Adapters/Symfony/Component/Process/Process.php +++ b/src/Adapters/Symfony/Component/Process/Process.php @@ -44,8 +44,6 @@ public function __construct( // @phpstan-ignore-next-line $this->inheritEnvironmentVariables(true); } - - parent::__construct($command, $cwd, $env, $input, $timeout); } public function getStartTime(): float diff --git a/src/Extension/ChromeDriverController.php b/src/Extension/ChromeDriverController.php index 16914577c..d425e3bae 100644 --- a/src/Extension/ChromeDriverController.php +++ b/src/Extension/ChromeDriverController.php @@ -103,6 +103,9 @@ private function getPort(): int return (int)($config['port'] ?? 4444); } + /** + * @throws ExtensionException + */ private function getBinary(): ?string { $config = $this->config; diff --git a/src/Extension/MysqlServerController.php b/src/Extension/MysqlServerController.php new file mode 100644 index 000000000..5c0a2fdaa --- /dev/null +++ b/src/Extension/MysqlServerController.php @@ -0,0 +1,237 @@ +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 2ae985641..6571372b0 100644 --- a/src/ManagedProcess/ChromeDriver.php +++ b/src/ManagedProcess/ChromeDriver.php @@ -60,7 +60,7 @@ public function __construct( /** * @throws RuntimeException */ - public function doStart(): void + private function doStart(): void { $command = array_merge([$this->chromeDriverBinary, '--port=' . $this->port], $this->arguments); $process = new Process($command); @@ -79,7 +79,7 @@ private function confirmStart(Process $process): void $start = time(); $output = $process->getOutput(); while (time() < $start + 30) { - if (strpos($output, 'ChromeDriver was started successfully.') !== false) { + if (strpos($output, 'ChromeDriver was started successfully') !== false) { return; } if ($process->getExitCode() !== null) { diff --git a/src/ManagedProcess/ManagedProcessTrait.php b/src/ManagedProcess/ManagedProcessTrait.php index c6283ee41..b83659553 100644 --- a/src/ManagedProcess/ManagedProcessTrait.php +++ b/src/ManagedProcess/ManagedProcessTrait.php @@ -49,8 +49,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..9a99ee121 --- /dev/null +++ b/src/ManagedProcess/MysqlServer.php @@ -0,0 +1,606 @@ +port = $port; + $this->database = $database; + $this->user = $user; + $this->password = $password; + if ($binary) { + $binary = FS::normalizePath($binary); + $this->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 setStartWaitTime(float $param): void + { + $this->startWaitTime = $param; + } + + 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; + } + + ($nullsafeVariable1 = $this->output) ? $nullsafeVariable1->writeln("Initializing MySQL Server ...", OutputInterface::VERBOSITY_DEBUG) : null; + $process = new Process($this->getInitializeCommand()); + $process->mustRun(); + ($nullsafeVariable2 = $this->output) ? $nullsafeVariable2->writeln('MySQL Server initialized.', OutputInterface::VERBOSITY_DEBUG) : null; + } + + 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; + switch ($this->machineInformation->getOperatingSystem()) { + case MachineInformation::OS_DARWIN: + $archiveExtension = '.tar.gz'; + break; + case MachineInformation::OS_WINDOWS: + $archiveExtension = '.zip'; + break; + default: + $archiveExtension = '.tar.xz'; + break; + } + $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), + '--log-error=' . $this->getErrorLogPath($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 { + $process->start(); + $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( + "Could not start MySQL Server at $this->directory\n" . $e->getMessage(), + self::ERR_MYSQL_SERVER_START_FAILED, + $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; + } + + private function getRootPDOOrNot(): ?\PDO + { + try { + return $this->getRootPDO(); + } catch (\Throwable $e) { + 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 { + ($nullsafeVariable3 = $this->output) ? $nullsafeVariable3->writeln("Downloading MySQL Server archive from $archiveUrl ...", OutputInterface::VERBOSITY_DEBUG) : null; + Download::fileFromUrl($archiveUrl, $archivePath); + ($nullsafeVariable4 = $this->output) ? $nullsafeVariable4->writeln('Downloaded MySQL Server archive.', OutputInterface::VERBOSITY_DEBUG) : null; + } 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); + + ($nullsafeVariable5 = $this->output) ? $nullsafeVariable5->writeln("Extracting MySQL Server archive from $mysqlServerArchivePath ...", OutputInterface::VERBOSITY_DEBUG) : null; + $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 + ); + } + ($nullsafeVariable6 = $this->output) ? $nullsafeVariable6->writeln('Extracted MySQL Server archive.', OutputInterface::VERBOSITY_DEBUG) : null; + } + + 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; + } + + 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/src/ManagedProcess/PhpBuiltInServer.php b/src/ManagedProcess/PhpBuiltInServer.php index f314dcb21..ef338932a 100644 --- a/src/ManagedProcess/PhpBuiltInServer.php +++ b/src/ManagedProcess/PhpBuiltInServer.php @@ -65,7 +65,7 @@ public function __construct(string $docRoot, int $port = 0, array $env = []) /** * @throws RuntimeException */ - public function doStart(): void + private function doStart(): void { $routerPathname = dirname(__DIR__, 2) . '/includes/cli-server/router.php'; $command = [ diff --git a/src/Module/WPDb.php b/src/Module/WPDb.php index cdf93a0a8..210adebea 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; @@ -201,12 +202,17 @@ class WPDb extends Db */ protected $blogUrl; + /** + * @var \Closure|null + */ + private $modesSetter; + /** * 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 */ @@ -225,9 +231,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. */ @@ -285,7 +291,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. @@ -418,7 +424,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. * * @@ -589,14 +595,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`. * * @@ -839,17 +845,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); @@ -892,7 +904,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. @@ -923,8 +935,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`. @@ -1026,8 +1038,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. @@ -1103,8 +1115,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`. @@ -1182,9 +1194,9 @@ public function grabTermTaxonomyIdFromDatabase(array $criteria) * $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 { @@ -1214,7 +1226,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. * @@ -1222,7 +1234,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}"; @@ -1479,7 +1490,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 { @@ -1558,9 +1569,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`. @@ -1587,7 +1598,7 @@ public function grabUserMetaFromDatabase(int $userId, string $meta_key, bool $si return $value; } - $normalized[] =$value; + $normalized[] = $value; } return $normalized; @@ -1602,8 +1613,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. @@ -1631,7 +1642,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`. */ @@ -1653,7 +1664,7 @@ public function haveTransientInDatabase(string $transient, $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` */ @@ -1697,7 +1708,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 $value If set the option will only be removed if its value matches the passed one. */ public function dontHaveOptionInDatabase(string $key, $value = null): void @@ -1721,7 +1732,7 @@ public function dontHaveOptionInDatabase(string $key, $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`. @@ -1762,8 +1773,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. @@ -1773,6 +1782,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 { @@ -1793,11 +1804,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. @@ -1805,6 +1811,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 { @@ -1853,7 +1864,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 $value If set the option will only be removed it its value matches the specified one. */ public function dontHaveSiteOptionInDatabase(string $key, $value = null): void @@ -1875,7 +1886,7 @@ public function dontHaveSiteOptionInDatabase(string $key, $value = null): void * $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` @@ -1984,7 +1995,7 @@ public function grabSiteTransientFromDatabase(string $key) * $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 $value If provided then the assertion will include the value. * * @throws JsonException @@ -2077,7 +2088,7 @@ public function seeSiteOptionInDatabase($criteriaOrName, $value = null): void * `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. * @@ -2125,7 +2136,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. */ @@ -2148,7 +2159,7 @@ protected function replaceNumbersInArray($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. */ @@ -2199,7 +2210,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. */ @@ -2301,8 +2312,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. @@ -2327,7 +2338,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`. @@ -2391,8 +2402,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. @@ -2435,7 +2446,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. @@ -2454,7 +2465,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. @@ -2527,7 +2538,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. @@ -2598,9 +2609,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. @@ -2658,14 +2669,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. * @@ -2778,7 +2789,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 @@ -2812,9 +2823,9 @@ public function haveUserCapabilitiesInDatabase(int $userId, $role): array * $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. @@ -2865,7 +2876,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 @@ -2910,9 +2921,9 @@ public function haveUserLevelsInDatabase(int $userId, $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 +3229,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 +3269,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 +3347,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 +3432,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 +3445,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('No blog found matching criteria ' . - json_encode($criteria, JSON_PRETTY_PRINT)); + $this->debug( + 'No blog found matching criteria ' . + json_encode($criteria, JSON_PRETTY_PRINT) + ); return; } @@ -3552,10 +3565,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 +3592,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 +3639,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 +3702,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 +3725,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 +3964,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 +4109,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 +4151,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 +4212,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($userIdOrLogin, bool $purgeMeta = true): void { @@ -4253,9 +4266,9 @@ public function grabUserIdFromDatabase(string $userLogin) * $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 +4297,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 +4487,7 @@ protected function loadDumpUsingDriver($databaseKey) /** * 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($databaseKey = null, $databaseConfig = null) @@ -4496,14 +4509,14 @@ public function _loadDump($databaseKey = null, $databaseConfig = null) * $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 +4646,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 +4662,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 +4721,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. @@ -4929,4 +4942,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/src/Module/WPLoader.php b/src/Module/WPLoader.php index 04c76fade..5fcba7bff 100644 --- a/src/Module/WPLoader.php +++ b/src/Module/WPLoader.php @@ -202,6 +202,10 @@ class WPLoader extends Module * @var \lucatume\WPBrowser\WordPress\CodeExecution\CodeExecutionFactory|null */ private $codeExecutionFactory; + /** + * @var bool + */ + private $didLoadWordPress = false; public function _getBootstrapOutput(): string { @@ -213,6 +217,37 @@ public function _getInstallationOutput(): string return $this->installationOutput; } + public function _didLoadWordPress(): bool + { + return $this->didLoadWordPress; + } + + /** + * Get the absolute path to the mu-plugins directory. + * + * The value will first look at the `WPMU_PLUGIN_DIR` constant, then the `WP_CONTENT_DIR` configuration parameter, + * and will, finally, look in the default path from the WordPress root directory. + * + * @param string $path + * + * @return string + * @since TBD + */ + public function getMuPluginsFolder(string $path = ''): string + { + /** @var array{WPMU_PLUGIN_DIR?: string, WP_CONTENT_DIR?: string} $config */ + $config = $this->config; + $candidates = array_filter([ + $config['WPMU_PLUGIN_DIR'] ?? null, + isset($config['WP_CONTENT_DIR']) ? rtrim($config['WP_CONTENT_DIR'], '\\/') . '/mu-plugins' : null, + $this->installation->getMuPluginsDir() + ]); + /** @var string $muPluginsDir */ + $muPluginsDir = reset($candidates); + + return rtrim($muPluginsDir, '\\/') . '/' . ($path ? ltrim($path, '\\/') : ''); + } + protected function validateConfig(): void { // Coming from required fields, the values are now defined. @@ -460,10 +495,10 @@ public function _initialize(): void $this->installation = new Installation($wpRootDir); } - if ($db instanceof SqliteDatabase && !is_file($this->installation->getContentDir('db.php'))) { + if ($db instanceof SqliteDatabase && !is_file($this->getContentFolder('db.php'))) { Installation::placeSqliteMuPlugin( - $this->installation->getMuPluginsDir(), - $this->installation->getContentDir() + $this->getMuPluginsFolder(), + $this->getContentFolder() ); } @@ -496,7 +531,16 @@ public function _initialize(): void if ($this->installation->isConfigured()) { foreach (['WP_CONTENT_DIR', 'WP_PLUGIN_DIR', 'WPMU_PLUGIN_DIR'] as $pathConst) { $constValue = $this->installation->getState()->getConstant($pathConst); + if ($constValue && is_string($constValue)) { + if (isset($config[$pathConst])) { + throw new ModuleConfigException( + $this, + "Both the installation wp-config.php file and the module configuration define a " . + "{$pathConst} constant: only one can be set." + ); + } + $config[$pathConst] = $constValue; } } @@ -515,10 +559,6 @@ public function _initialize(): void $this->checkInstallationToLoadOnly(); $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); - }); - return; } @@ -529,7 +569,18 @@ public function _initialize(): void WPTestCase::beStrictAboutWpdbConnectionId($config['beStrictAboutWpdbConnectionId']); - $this->loadWordPress(); + $this->_loadWordPress(); + } + + /** + * @param array $settings + * + * @return void + */ + public function _beforeSuite(array $settings = []) + { + parent::_beforeSuite($settings); + $this->_loadWordPress(); } /** @@ -579,11 +630,20 @@ private function ensureDbModuleCompat(): void /** * Loads WordPress calling the bootstrap file. * - * * @throws Throwable + * + * @internal This method is not part of the module API. */ - private function loadWordPress(bool $loadOnly = false): void + public function _loadWordPress(?bool $loadOnly = null): void { + if ($this->didLoadWordPress) { + return; + } + + $config = $this->config; + /** @var array{loadOnly: bool} $config */ + $loadOnly = $loadOnly ?? $config['loadOnly']; + $this->loadConfigFiles(); if ($loadOnly) { @@ -595,6 +655,8 @@ private function loadWordPress(bool $loadOnly = false): void $this->installAndBootstrapInstallation(); } + $this->didLoadWordPress = true; + wp_cache_flush(); $this->factoryStore = new FactoryStore(); @@ -612,8 +674,9 @@ private function loadWordPress(bool $loadOnly = false): void /** * Returns the absolute path to the plugins directory. * - * The value will first look at the `WP_PLUGIN_DIR` constant, then the `pluginsFolder` configuration parameter - * and will, finally, look in the default path from the WordPress root directory. + * The value will first look at the `WP_PLUGIN_DIR` constant, then the `pluginsFolder` configuration parameter, + * then the `WP_CONTENT_DIR` configuration parameter, and will, finally, look in the default path from the + * WordPress root directory. * * @example * ```php @@ -628,7 +691,18 @@ private function loadWordPress(bool $loadOnly = false): void */ public function getPluginsFolder(string $path = ''): string { - return $this->installation->getPluginsDir($path); + /** @var array{pluginsFolder?: string, WP_PLUGIN_DIR?: string,WP_CONTENT_DIR?: string} $config */ + $config = $this->config; + $candidates = array_filter([ + $config['WP_PLUGIN_DIR'] ?? null, + $config['pluginsFolder'] ?? null, + isset($config['WP_CONTENT_DIR']) ? rtrim($config['WP_CONTENT_DIR'], '\\/') . '/plugins' : null, + $this->installation->getPluginsDir() + ]); + /** @var string $pluginDir */ + $pluginDir = reset($candidates); + + return rtrim($pluginDir, '\\/') . '/' . ($path ? ltrim($path, '\\/') : ''); } /** @@ -860,6 +934,9 @@ private function loadConfigFiles(): void /** * Returns the absolute path to the WordPress content directory. * + * The value will first look at the `WP_CONTENT_DIR` configuration parameter, and will, finally, look in the + * default path from the WordPress root directory. + * * @example * ```php * $content = $this->getContentFolder(); @@ -873,7 +950,16 @@ private function loadConfigFiles(): void */ public function getContentFolder(string $path = ''): string { - return $this->installation->getContentDir($path); + /** @var array{WP_CONTENT_DIR?: string} $config */ + $config = $this->config; + $candidates = array_filter([ + $config['WP_CONTENT_DIR'] ?? null, + $this->installation->getContentDir() + ]); + /** @var string $contentDir */ + $contentDir = reset($candidates); + + return rtrim($contentDir, '\\/') . '/' . ($path ? ltrim($path, '\\/') : ''); } private function getCodeExecutionFactory(): CodeExecutionFactory @@ -1087,7 +1173,7 @@ private function activatePluginsTheme(array $plugins): array wp_cache_delete('alloptions', 'options'); // Do not include external plugins, it would create issues at this stage. - $pluginsDir = $this->installation->getPluginsDir(); + $pluginsDir = $this->getPluginsFolder(); return array_values( array_filter( @@ -1131,7 +1217,7 @@ private function muActivatePluginsTheme(array $plugins): array wp_cache_delete("1::active_sitewide_plugins", 'site-options'); // Do not include external plugins, it would create issues at this stage. - $pluginsDir = $this->installation->getPluginsDir(); + $pluginsDir = $this->getPluginsFolder(); $validPlugins = array_values( array_filter( $plugins, @@ -1176,7 +1262,7 @@ private function includeAllPlugins(array $plugins, bool $isMultisite): void $activePlugins = []; } - $pluginsDir = $this->installation->getPluginsDir(); + $pluginsDir = $this->getPluginsFolder(); foreach ($plugins as $plugin) { if (!is_file($pluginsDir . "/$plugin")) { diff --git a/src/Process/Loop.php b/src/Process/Loop.php index 41dff3691..2e7b27619 100644 --- a/src/Process/Loop.php +++ b/src/Process/Loop.php @@ -91,6 +91,7 @@ public function __construct( * rethrow?: bool, * requireFiles?: array, * cwd?: string, + * use_file_payloads?: bool, * } $options * * @throws ProcessException @@ -99,7 +100,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/src/TestCase/WPTestCase.php b/src/TestCase/WPTestCase.php index 14165f248..8e46a284a 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() @@ -94,9 +96,17 @@ class WPTestCase extends Unit * @var Actor */ 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 $backupGlobalsBlacklist = [ 'wpdb', 'wp_query', @@ -130,9 +140,18 @@ class WPTestCase extends Unit '_wpTestsBackupStaticAttributes', '_wpTestsBackupStaticAttributesExcludeList' ]; - // Backup, and reset, static class attributes between tests. + /** + * Backup, and reset, static class attributes between tests for PHPUnit < 10.0.0. + * + * @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. + * PHPUnit < 10.0.0. + * + * @var array> + */ protected $backupStaticAttributesBlacklist = [ // WordPress 'WP_Block_Type_Registry' => ['instance'], @@ -157,14 +176,14 @@ class WPTestCase extends Unit */ private $requestTime; /** - * @param array $data + * @var array */ - public function __construct(?string $name = null, array $data = [], $dataName = '') + private $attachmentsAddedDuringTest = []; + private function initBackupGlobalsProperties():void { global $_wpTestsBackupGlobals, - $_wpTestsBackupGlobalsExcludeList, - $_wpTestsBackupStaticAttributes, - $_wpTestsBackupStaticAttributesExcludeList; + $_wpTestsBackupGlobalsExcludeList; + $phpunitVersion = (int)PHPUnitVersion::series(); $backupGlobalsReflectionProperty = new ReflectionProperty($this, 'backupGlobals'); $backupGlobalsReflectionProperty->setAccessible(true); @@ -173,14 +192,15 @@ public function __construct(?string $name = null, array $data = [], $dataName = $this->backupGlobals = $_wpTestsBackupGlobals; } - if (property_exists($this, 'backupGlobalsExcludeList')) { - $backupGlobalsExcludeListReflectionProperty = new ReflectionProperty($this, 'backupGlobalsExcludeList'); - $backupGlobalsExcludeListReflectionProperty->setAccessible(true); - } else { + if ($phpunitVersion < 9) { // Older versions of PHPUnit. $backupGlobalsExcludeListReflectionProperty = new ReflectionProperty($this, 'backupGlobalsBlacklist'); $backupGlobalsExcludeListReflectionProperty->setAccessible(true); + } else { + $backupGlobalsExcludeListReflectionProperty = new ReflectionProperty($this, 'backupGlobalsExcludeList'); + $backupGlobalsExcludeListReflectionProperty->setAccessible(true); } + $backupGlobalsExcludeListReflectionProperty->setAccessible(true); $isDefinedInThis = $backupGlobalsExcludeListReflectionProperty->getDeclaringClass() ->getName() !== WPTestCase::class; @@ -193,8 +213,15 @@ 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; @@ -202,20 +229,10 @@ public function __construct(?string $name = null, array $data = [], $dataName = $this->backupStaticAttributes = $_wpTestsBackupStaticAttributes; } - if (property_exists($this, 'backupStaticAttributesExcludeList')) { - $backupStaticAttributesExcludeListReflectionProperty = new ReflectionProperty( - $this, - 'backupStaticAttributesExcludeList' - ); - $backupStaticAttributesExcludeListReflectionProperty->setAccessible(true); - } else { - // Older versions of PHPUnit. - $backupStaticAttributesExcludeListReflectionProperty = new ReflectionProperty( - $this, - 'backupStaticAttributesBlacklist' - ); - $backupStaticAttributesExcludeListReflectionProperty->setAccessible(true); - } + $backupStaticAttributesExcludeListReflectionProperty = new ReflectionProperty( + $this, + $backupStaticAttributesExcludeListPropertyName + ); $backupStaticAttributesExcludeListReflectionProperty->setAccessible(true); $isDefinedInThis = $backupStaticAttributesExcludeListReflectionProperty->getDeclaringClass() ->getName() !== WPTestCase::class; @@ -228,8 +245,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->backupStaticAttributesBlacklist, $_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, $data, $dataName); + parent::__construct($name ?: 'testMethod', $data, $dataName); } /** * @var array @@ -239,12 +292,13 @@ public function __construct(?string $name = null, array $data = [], $dataName = * @var array */ private static $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); @@ -333,7 +387,7 @@ public static function __callStatic(string $name, array $arguments) */ public function __call(string $name, array $arguments) { - $coreTestCase = self::getCoreTestCase(); + $coreTestCase = self::getCoreTestCase($name); $reflectionMethod = new ReflectionMethod($coreTestCase, $name); $reflectionMethod->setAccessible(true); return $reflectionMethod->invokeArgs($coreTestCase, $arguments); @@ -358,7 +412,7 @@ private function isCoreTestCaseProperty(string $name): bool static function (ReflectionProperty $p) { return $p->getName(); }, - (new \ReflectionClass(self::getCoreTestCase()))->getProperties() + (new ReflectionClass(self::getCoreTestCase()))->getProperties() ); } @@ -374,15 +428,11 @@ public function __get(string $name) 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; } /** @@ -397,7 +447,7 @@ public function __set(string $name, $value): void return; } - $coreTestCase = self::getCoreTestCase(); + $coreTestCase = self::getCoreTestCase('__set'); $reflectionProperty = new ReflectionProperty($coreTestCase, $name); $reflectionProperty->setAccessible(true); $reflectionProperty->setValue($coreTestCase, $value); @@ -411,9 +461,33 @@ 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); + } + + // @phpstan-ignore-next-line PHPUnit >= 10.0.0. + return $withDataSet ? $this->nameWithDataSet() : $this->name(); + } + // @phpstan-ignore-next-line Used in the setUp method of the test case trait. + private function recordAttachmentAddedDuringTest(): void + { + add_action('add_attachment', function (int $post_id): void { + $this->attachmentsAddedDuringTest[] = $post_id; + }); + } + // @phpstan-ignore-next-line Used in the tearDown method of the test case trait. + private function removeAttachmentsAddedDuringTest(): void + { + foreach ($this->attachmentsAddedDuringTest as $post_id) { + wp_delete_attachment($post_id, true); + } + } } diff --git a/src/TestCase/WPTestCasePHPUnitMethodsTrait.php b/src/TestCase/WPTestCasePHPUnitMethodsTrait.php index 19aad0651..42fba5f2a 100644 --- a/src/TestCase/WPTestCasePHPUnitMethodsTrait.php +++ b/src/TestCase/WPTestCasePHPUnitMethodsTrait.php @@ -33,10 +33,12 @@ protected function setUp() //@phpstan-ignore-line $this->set_up(); //@phpstan-ignore-line magic __call $this->backupAdditionalGlobals(); + $this->recordAttachmentAddedDuringTest(); } protected function tearDown() //@phpstan-ignore-line { + $this->removeAttachmentsAddedDuringTest(); $this->restoreAdditionalGlobals(); $this->tear_down(); //@phpstan-ignore-line magic __call parent::tearDown(); @@ -71,10 +73,12 @@ protected function setUp(): void $this->set_up(); //@phpstan-ignore-line magic __call $this->backupAdditionalGlobals(); + $this->recordAttachmentAddedDuringTest(); } protected function tearDown(): void { + $this->removeAttachmentsAddedDuringTest(); $this->restoreAdditionalGlobals(); $this->tear_down(); //@phpstan-ignore-line magic __call parent::tearDown(); diff --git a/src/Traits/UopzFunctions.php b/src/Traits/UopzFunctions.php index 2e07e1b21..f0f0d921c 100644 --- a/src/Traits/UopzFunctions.php +++ b/src/Traits/UopzFunctions.php @@ -69,7 +69,7 @@ trait UopzFunctions /** * @param mixed $value */ - protected function setFunctionReturn(string $function, $value, bool $execute = false): void + protected function setFunctionReturn(string $function, $value, bool $execute = false): Closure { if (!function_exists('uopz_set_return')) { $this->markTestSkipped('This test requires the uopz extension'); @@ -77,6 +77,10 @@ protected function setFunctionReturn(string $function, $value, bool $execute = f uopz_set_return($function, $value, $execute); self::$uopzSetFunctionReturns[$function] = true; + + return function () use ($function) { + $this->unsetFunctionReturn($function); + }; } protected function unsetFunctionReturn(string $function): void @@ -92,11 +96,15 @@ protected function unsetFunctionReturn(string $function): void /** * @param mixed $value */ - protected function setMethodReturn(string $class, string $method, $value, bool $execute = false): void + protected function setMethodReturn(string $class, string $method, $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 @@ -111,7 +119,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'); @@ -119,6 +127,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 @@ -131,7 +143,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'); @@ -140,6 +152,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 @@ -157,7 +173,7 @@ protected function unsetMethodHook(string $class, string $method): void /** * @param mixed $value */ - protected function setConstant(string $constant, $value): void + protected function setConstant(string $constant, $value): Closure { if (!function_exists('uopz_redefine')) { $this->markTestSkipped('This test requires the uopz extension'); @@ -170,6 +186,10 @@ protected function setConstant(string $constant, $value): void uopz_redefine($constant, $value); } self::$uopzSetConstants[$constant] = $previousValue; + + return function () use ($constant) { + $this->unsetConstant($constant); + }; } protected function unsetConstant(string $constant): void @@ -191,7 +211,7 @@ protected function unsetConstant(string $constant): void /** * @param mixed $value */ - protected function setClassConstant(string $class, string $constant, $value): void + protected function setClassConstant(string $class, string $constant, $value): Closure { if (!function_exists('uopz_redefine')) { $this->markTestSkipped('This test requires the uopz extension'); @@ -202,6 +222,10 @@ protected function setClassConstant(string $class, string $constant, $value): vo : '__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 @@ -223,7 +247,7 @@ protected function unsetClassConstant(string $class, string $constant): void /** * @param mixed $mock */ - protected function setClassMock(string $class, $mock): void + protected function setClassMock(string $class, $mock): Closure { if (!function_exists('uopz_set_mock')) { $this->markTestSkipped('This test requires the uopz extension'); @@ -231,6 +255,10 @@ protected function setClassMock(string $class, $mock): void uopz_set_mock($class, $mock); self::$uopzSetClassMocks[$class] = true; + + return function () use ($class) { + $this->unsetClassMock($class); + }; } protected function unsetClassMock(string $class): void @@ -243,7 +271,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'); @@ -252,6 +280,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 @@ -265,7 +297,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'); @@ -274,6 +306,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 @@ -288,7 +324,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'); @@ -300,6 +336,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 @@ -321,7 +361,7 @@ protected function setObjectProperty( $classOrObject, string $property, $value - ): void { + ): Closure { if (!function_exists('uopz_set_property')) { $this->markTestSkipped('This test requires the uopz extension'); } @@ -330,6 +370,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); + }; } /** @@ -364,7 +408,7 @@ protected function resetObjectProperty($classOrObject, string $property): void /** * @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'); @@ -379,6 +423,10 @@ protected function setMethodStaticVariables(string $class, string $method, array } uopz_set_static($class, $method, $values); + + return function () use ($class, $method) { + $this->resetMethodStaticVariables($class, $method); + }; } /** @@ -427,7 +475,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'); @@ -442,6 +490,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 @@ -455,7 +507,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'); @@ -463,6 +515,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 b4490623b..5f47c04f3 100644 --- a/src/Utils/Filesystem.php +++ b/src/Utils/Filesystem.php @@ -430,4 +430,58 @@ private static function symfonyFilesystem(): SymfonyFilesystem self::$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..0414cfd1e --- /dev/null +++ b/src/Utils/MachineInformation.php @@ -0,0 +1,71 @@ +operatingSystem = self::OS_DARWIN; + break; + case 'lin': + $this->operatingSystem = self::OS_LINUX; + break; + case 'win': + $this->operatingSystem = self::OS_WINDOWS; + break; + default: + $this->operatingSystem = self::OS_UNKNOWN; + break; + } + + switch (strtolower(php_uname('m'))) { + case 'x86_64': + case 'amd64': + $this->architecture = self::ARCH_X86_64; + break; + case 'arm64': + case 'aarch64': + $this->architecture = self::ARCH_ARM64; + break; + default: + $this->architecture = self::ARCH_UNKNOWN; + break; + } + } + + 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/src/Utils/Property.php b/src/Utils/Property.php index 4231d2d7c..0e0cec5c0 100644 --- a/src/Utils/Property.php +++ b/src/Utils/Property.php @@ -49,14 +49,19 @@ public static function readPrivate($object, string $property) * @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, string $class, array $props) - { + public static function setPropertiesForClass( + $object, + string $class, + array $props, + array &$propsToSet = null + ) { if (!class_exists($class)) { throw new InvalidArgumentException( sprintf('Class "%s" does not exists', $class) @@ -89,6 +94,9 @@ public static function setPropertiesForClass($object, string $class, array $prop 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)]); + } } } @@ -115,9 +123,10 @@ public static function setPrivateProperties($object, array $props): void $class = get_class($object); } + $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); } } diff --git a/src/WordPress/LoadSandbox.php b/src/WordPress/LoadSandbox.php index 982b16ae7..fda309be8 100644 --- a/src/WordPress/LoadSandbox.php +++ b/src/WordPress/LoadSandbox.php @@ -80,6 +80,7 @@ class_exists(InstallationException::class); if (did_action('wp_loaded') >= 1) { return true; } + $reason = 'action wp_loaded not fired.'; if (count($this->redirects) > 0 && $this->redirects[0][1] === 302 @@ -112,7 +113,7 @@ class_exists(InstallationException::class); } // We do not know what happened, throw and try to be helpful. - throw InstallationException::becauseWordPressFailedToLoad($bodyContent); + throw InstallationException::becauseWordPressFailedToLoad($bodyContent ?: $reason); } public function logRedirection(string $location, int $status): string diff --git a/src/WordPress/PreloadFilters.php b/src/WordPress/PreloadFilters.php index a9e8be2d3..df1107d4a 100644 --- a/src/WordPress/PreloadFilters.php +++ b/src/WordPress/PreloadFilters.php @@ -34,7 +34,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][] = [ 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/_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/_support/Fork.php b/tests/_support/Fork.php new file mode 100644 index 000000000..9723fb594 --- /dev/null +++ b/tests/_support/Fork.php @@ -0,0 +1,224 @@ + + */ + private $ipcSocketChunkSize = 2048; + /** + * @var string + */ + private $terminator = self::DEFAULT_TERMINATOR; + + /** + * @return mixed + */ + public static function executeClosure( + \Closure $callback, + bool $quiet = false, + int $ipcSocketChunkSize = 2048, + string $terminator = self::DEFAULT_TERMINATOR + ) { + return (new self($callback)) + ->setQuiet($quiet) + ->setIpcSocketChunkSize($ipcSocketChunkSize) + ->setTerminator($terminator) + ->execute(); + } + + public function __construct(\Closure $callback) + { + $this->callback = $callback; + } + + public function setQuiet(bool $quiet): self + { + $this->quiet = $quiet; + return $this; + } + + /** + * @return mixed + */ + public function execute() + { + if (!(function_exists('pcntl_fork') && function_exists('posix_kill'))) { + throw new \RuntimeException('pcntl and posix extensions missing.'); + } + + $sockets = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP); + + if ($sockets === false) { + throw new \RuntimeException('Failed to create socket pair'); + } + + /** @var array{0: resource, 1: resource} $sockets */ + + $pid = pcntl_fork(); + if ($pid === -1) { + throw new \RuntimeException('Failed to fork'); + } + + + if ($pid === 0) { + $this->executeFork($sockets); + } + + return $this->executeMain($pid, $sockets); + } + + public function setIpcSocketChunkSize(int $ipcSocketChunkSize): self + { + if ($ipcSocketChunkSize < 0) { + throw new \InvalidArgumentException('ipcSocketChunkSize must be a positive integer'); + } + + $this->ipcSocketChunkSize = $ipcSocketChunkSize; + return $this; + } + + public function setTerminator(string $terminator): self + { + $this->terminator = $terminator; + return $this; + } + + /** + * @param array{0: resource, 1: resource} $sockets + */ + private function executeFork(array $sockets): void + { + fclose($sockets[1]); + $ipcSocket = $sockets[0]; + $pid = getmypid(); + $didWriteTerminator = false; + $terminator = $this->terminator; + + if ($pid === false) { + die('Failed to get pid'); + } + + if ($this->quiet) { + fclose(STDOUT); + fclose(STDERR); + } + + register_shutdown_function(static function () use ($pid, $ipcSocket, &$didWriteTerminator, $terminator) { + if (!$didWriteTerminator) { + fwrite($ipcSocket, $terminator); + $didWriteTerminator = true; + } + fclose($ipcSocket); + /** @noinspection PhpComposerExtensionStubsInspection */ + posix_kill($pid, 9 /* SIGKILL */); + }); + + try { + $result = ($this->callback)(); + $resultClosure = new SerializableClosure(static function () use ($result) { + return $result; + }); + $resultPayload = serialize($resultClosure); + } catch (\Throwable $throwable) { + $resultPayload = serialize(new SerializableThrowable($throwable)); + } finally { + if (!isset($resultPayload)) { + // Something went wrong. + fwrite($ipcSocket, serialize(null)); + fwrite($ipcSocket, $this->terminator); + $didWriteTerminator = true; + /** @noinspection PhpComposerExtensionStubsInspection */ + posix_kill($pid, 9 /* SIGKILL */); + } + } + + $offset = 0; + while (true) { + $chunk = substr($resultPayload, $offset, $this->ipcSocketChunkSize); + + if ($chunk === '') { + break; + } + + fwrite($ipcSocket, $chunk); + $offset += $this->ipcSocketChunkSize; + } + fwrite($ipcSocket, $this->terminator); + $didWriteTerminator = true; + fclose($ipcSocket); + + // Kill the child process now with a signal that will not run shutdown handlers. + /** @noinspection PhpComposerExtensionStubsInspection */ + posix_kill($pid, 9 /* SIGKILL */); + } + + /** + * @param array{0: resource, 1: resource} $sockets + * @throws \Throwable + * @return mixed + */ + private function executeMain(int $pid, array $sockets) + { + fclose($sockets[0]); + $resultPayload = ''; + + /** @noinspection PhpComposerExtensionStubsInspection */ + while (pcntl_wait($status, 1 /* WNOHANG */) <= 0) { + $chunk = fread($sockets[1], $this->ipcSocketChunkSize); + $resultPayload .= $chunk; + } + + while (substr_compare($resultPayload, $this->terminator, -strlen($this->terminator)) !== 0) { + $chunk = fread($sockets[1], $this->ipcSocketChunkSize); + $resultPayload .= $chunk; + } + + fclose($sockets[1]); + + if (substr_compare($resultPayload, $this->terminator, -strlen($this->terminator)) === 0) { + $resultPayload = substr($resultPayload, 0, -strlen($this->terminator)); + } + + try { + /** @var SerializableClosure|SerializableThrowable $unserializedPayload */ + $unserializedPayload = @unserialize($resultPayload); + $result = $unserializedPayload instanceof SerializableThrowable ? + $unserializedPayload->getThrowable() : $unserializedPayload->getClosure()(); + } catch (\Throwable $t) { + $result = $resultPayload; + } + + if ($result instanceof \Throwable) { + throw $result; + } + + /** @noinspection PhpComposerExtensionStubsInspection */ + posix_kill($pid, 9 /* SIGKILL */); + + return $result; + } +} diff --git a/tests/_support/StubClassFactory.php b/tests/_support/StubClassFactory.php index 2a5b92aee..b3d560ece 100644 --- a/tests/_support/StubClassFactory.php +++ b/tests/_support/StubClassFactory.php @@ -5,6 +5,7 @@ use Codeception\Stub; use Exception; use lucatume\WPBrowser\Utils\Property; +use PHPUnit\Runner\Version as PHPUnitVersion; use ReflectionException; use ReflectionMethod; @@ -13,46 +14,114 @@ class StubClassFactory /** * @var string */ - private static $classTemplate = 'class %1$s extends %2$s + private static $classTemplatePhpUnitLt10 = 'class %1$s extends %2$s { public function __construct(%3$s) { - $this->_stub = %4$s::connectInvocationMocker($this); + %4$s::connectToStub($this, true); %4$s::assertConstructorConditions("%1$s", func_get_args()); + %4$s::setMockForClassName("%1$s", $this); } }'; /** - * @var mixed[] + * @var string */ - private static $stubParametersByClassName = []; + private static $classTemplatePhpUnitEq10 = 'class %1$s extends %2$s +{ + public function __construct(%3$s) + { + %4$s::connectToStub($this, false); + %4$s::assertConstructorConditions("%1$s", func_get_args()); + %4$s::setMockForClassName("%1$s", $this); + } +}'; /** - * @var mixed[] + * @var string + */ + private static $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 $constructorAssertions = []; + /** + * @var array + */ + private static $stubByClassName = []; + /** + * @var array + */ + private static $mockByClassName = []; + /** + * @var}> + */ + private static $stubParametersByClassName = []; + + /** + * @param object $mock + */ + public static function setMockForClassName(string $mockClassName, $mock): void + { + self::$mockByClassName[$mockClassName] = $mock; + } + public static function tearDown(): void { - self::$stubParametersByClassName = []; + self::$stubByClassName = []; self::$constructorAssertions = []; + self::$mockByClassName = []; } /** - * @throws Exception * @param object $mock */ - public static function connectInvocationMocker($mock): void - { + public static function connectToStub($mock, bool $includeOriginalObject): 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'), - ]); + 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'), + ]); + } unset($stub); } + /** + * @throws ReflectionException + * @return object + */ + public static function getPHPUnitStateObject(string $mockClassName) + { + $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])) { @@ -62,17 +131,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( @@ -88,17 +162,46 @@ public static function makeEmptyClass(string $class, array $parameters): string unset($parameters['__construct']); } + foreach ($parameters as &$value) { + if ($value === '__itself') { + $value = function () use ($mockClassName) { + return 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::$stubByClassName[$mockClassName] = $codeceptionStub; self::$stubParametersByClassName[$mockClassName] = [$class, $parameters]; return $mockClassName; } + + /** + * @param string $mockClassName + * @return object + */ + private static function getMockByClassName(string $mockClassName) + { + return self::$mockByClassName[$mockClassName]; + } } diff --git a/tests/_support/Traits/InstallationMocks.php b/tests/_support/Traits/InstallationMocks.php new file mode 100644 index 000000000..e28c0351b --- /dev/null +++ b/tests/_support/Traits/InstallationMocks.php @@ -0,0 +1,103 @@ + [ + 'version.php' => << << ' ' [ + 'version.php' => << ' 'getReturnValue(); @@ -41,9 +42,13 @@ protected function assertInIsolation(Closure $runAssertions, string $cwd = null, throw $returnValue; } 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; } diff --git a/tests/acceptance.suite.dist.yml b/tests/acceptance.suite.dist.yml index 093b617a3..38bf65850 100644 --- a/tests/acceptance.suite.dist.yml +++ b/tests/acceptance.suite.dist.yml @@ -23,7 +23,8 @@ modules: X_WPBROWSER_REQUEST: 1 X_TEST_REQUEST: 1 X_APM_REQUEST: 1 - connect_timeout: 3 + connect_timeout: 3.0 + timeout: 3.0 lucatume\WPBrowser\Module\WPDb: dsn: '%WORDPRESS_DB_DSN%' user: %WORDPRESS_DB_USER% diff --git a/tests/unit/lucatume/WPBrowser/Command/RunAllTest.php b/tests/unit/lucatume/WPBrowser/Command/RunAllTest.php index e41272310..6a5baab5c 100644 --- a/tests/unit/lucatume/WPBrowser/Command/RunAllTest.php +++ b/tests/unit/lucatume/WPBrowser/Command/RunAllTest.php @@ -52,6 +52,7 @@ public function should_invoke_codecept_bin_once_for_each_suite(): void 'isSuccessful' => function () { return true; }, + 'setTimeout' => '__itself', ]; $this->setClassMock(Process::class, $this->makeEmptyClass(Process::class, $mockParams)); $this->setMethodReturn(Configuration::class, 'suites', ['suite-1', 'suite-2', 'suite-3']); @@ -90,6 +91,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..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,19 +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', + 'getPid' => 2389, ]) ); @@ -158,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); } /** @@ -176,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 = []; @@ -198,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 = []; @@ -220,14 +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); }, - 'stop' => 0 + 'mustRun' => '__itself', + 'stop' => 0, + 'getPid' => 2389 ]) ); $config = ['suites' => ['end2end'], 'compose-file' => 'docker-compose.yml']; @@ -236,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); } @@ -260,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.'); } ]) ); @@ -283,7 +345,33 @@ 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->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 = []; @@ -293,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])); } @@ -305,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/'); @@ -338,25 +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, [ - '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.'); @@ -370,13 +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, [ - 'getOutput' => static function () { - return Yaml::dump(['services' => ['foo' => ['ports' => ['8088:80']]]]); + '__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 () 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']; @@ -388,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']]]]], @@ -398,5 +570,6 @@ public function should_produce_information_correctly(): void $extension->stop($this->output); $this->assertEquals(['status' => 'down', 'config' => ''], $extension->getInfo()); + $this->assertFalse($runningFileExists); } } diff --git a/tests/unit/lucatume/WPBrowser/Extension/MysqlServerControllerTest.php b/tests/unit/lucatume/WPBrowser/Extension/MysqlServerControllerTest.php new file mode 100644 index 000000000..a9ad359fe --- /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) { + if ($file === $pidFile) { + return '12345'; + } + return file_get_contents($file); + }, true); + $this->setFunctionReturn('exec', function (string $command) { + 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) { + if ($file === $pidFile) { + return false; + } + return file_get_contents($file); + }, true); + $this->setFunctionReturn('exec', function (string $command) { + 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) { + if ($file === $pidFile) { + return '12345'; + } + return file_get_contents($file); + }, true); + $this->setFunctionReturn('exec', function (string $command) { + 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/Extension/SymlinkerTest.php b/tests/unit/lucatume/WPBrowser/Extension/SymlinkerTest.php index e9c41c41c..d00987d3e 100644 --- a/tests/unit/lucatume/WPBrowser/Extension/SymlinkerTest.php +++ b/tests/unit/lucatume/WPBrowser/Extension/SymlinkerTest.php @@ -10,6 +10,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; @@ -18,6 +19,7 @@ class SymlinkerTest extends Unit { use LoopIsolation; + use TmpFilesCleanup; private function getSuiteEvent(): SuiteEvent { diff --git a/tests/unit/lucatume/WPBrowser/ManagedProcess/ChromedriverTest.php b/tests/unit/lucatume/WPBrowser/ManagedProcess/ChromedriverTest.php index 8d884eabb..7bd09f95d 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, ]); diff --git a/tests/unit/lucatume/WPBrowser/ManagedProcess/MysqlServerTest.php b/tests/unit/lucatume/WPBrowser/ManagedProcess/MysqlServerTest.php new file mode 100644 index 000000000..b2a42f2f0 --- /dev/null +++ b/tests/unit/lucatume/WPBrowser/ManagedProcess/MysqlServerTest.php @@ -0,0 +1,1077 @@ +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(), + '--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' => 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) { + $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(), + '--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 + ] + ) + ); + + // 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) { + $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' => function () { + 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(), + '--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 + ] + ) + ); + + // 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) { + $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' => function () { + throw new AssertionFailedError('No process should be started.'); + }, + ])); + + $this->setClassMock(PDO::class, $this->makeEmptyClass(PDO::class, [ + '__construct' => function () { + 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(); + } + + 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' => 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) { + $queries[] = $query; + return 1; + } + ])); + + $this->expectException(RuntimeException::class); + $this->expectExceptionCode(MysqlServer::ERR_MYSQL_SERVER_NEVER_BECAME_AVAILABLE); + + $mysqlServer->start(); + } +} diff --git a/tests/unit/lucatume/WPBrowser/Module/WPDbTest.php b/tests/unit/lucatume/WPBrowser/Module/WPDbTest.php index 8dc674a0a..89f3906ff 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 (strncmp($query, 'SET SESSION sql_mode=', strlen('SET SESSION sql_mode=')) === 0) { + $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); + } } diff --git a/tests/unit/lucatume/WPBrowser/Module/WPFilesystemTest.php b/tests/unit/lucatume/WPBrowser/Module/WPFilesystemTest.php index 6edf81a4d..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'); } /** @@ -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/WPLoaderArbitraryPluginLocationTest.php b/tests/unit/lucatume/WPBrowser/Module/WPLoaderArbitraryPluginLocationTest.php index ccf451b61..d084f4077 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 1998369c7..1a51a9345 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/WPLoaderLoadOnlyTest.php b/tests/unit/lucatume/WPBrowser/Module/WPLoaderLoadOnlyTest.php new file mode 100644 index 000000000..bfd4dbc72 --- /dev/null +++ b/tests/unit/lucatume/WPBrowser/Module/WPLoaderLoadOnlyTest.php @@ -0,0 +1,82 @@ +makeMockConfiguredInstallation(); + $moduleContainer = new ModuleContainer(new Di(), []); + $module = new WPLoader($moduleContainer, [ + 'dbUrl' => $dbUrl, + 'wpRootFolder' => $wpRootFolder, + 'loadOnly' => true, + ]); + + Fork::executeClosure(function () use ($module) { + // WordPress' functions are stubbed by wordpress-stubs in unit tests: override them to do something. + $did_actions = []; + uopz_set_return('do_action', static function ($action) use (&$did_actions) { + $did_actions[$action] = true; + }, true); + uopz_set_return('did_action', static function ($action) use (&$did_actions) { + return isset($did_actions[$action]); + }, true); + // Partial mocking the function that would load WordPress. + uopz_set_return(WPLoader::class, 'installAndBootstrapInstallation', function () { + $this->fail('The WPLoader::installAndBootstrapInstallation method should not be called'); + }, true); + + $module->_initialize(); + + $this->assertFalse($module->_didLoadWordPress()); + + $module->_beforeSuite(); + + $this->assertTrue($module->_didLoadWordPress()); + }); + } + + public function testWillLoadWordPressInInitializeWhenLoadOnlyIsFalse(): void + { + [$wpRootFolder, $dbUrl] = $this->makeMockConfiguredInstallation(); + $moduleContainer = new ModuleContainer(new Di(), []); + $module = new WPLoader($moduleContainer, [ + 'dbUrl' => $dbUrl, + 'wpRootFolder' => $wpRootFolder, + 'loadOnly' => false, + ]); + + Fork::executeClosure(function () use ($module) { + // WordPress' functions are stubbed by wordpress-stubs in unit tests: override them to do something. + $did_actions = []; + uopz_set_return('do_action', static function ($action) use (&$did_actions) { + $did_actions[$action] = true; + }, true); + uopz_set_return('did_action', static function ($action) use (&$did_actions) { + return isset($did_actions[$action]); + }, true); + // Partial mocking the function that would load WordPress. + uopz_set_return(WPLoader::class, 'installAndBootstrapInstallation', function () { + return true; + }, true); + + $module->_initialize(); + + $this->assertTrue($module->_didLoadWordPress()); + + $module->_beforeSuite(); + + $this->assertTrue($module->_didLoadWordPress()); + }); + } +} diff --git a/tests/unit/lucatume/WPBrowser/Module/WPLoaderScaffoldedInstallationCustomLocationsTest.php b/tests/unit/lucatume/WPBrowser/Module/WPLoaderScaffoldedInstallationCustomLocationsTest.php new file mode 100644 index 000000000..e670df672 --- /dev/null +++ b/tests/unit/lucatume/WPBrowser/Module/WPLoaderScaffoldedInstallationCustomLocationsTest.php @@ -0,0 +1,414 @@ +makeMockConfiguredInstallation(); + $moduleContainer = new ModuleContainer(new Di(), []); + $module = new WPLoader($moduleContainer, [ + 'dbUrl' => $dbUrl, + 'wpRootFolder' => $wpRootFolder, + 'loadOnly' => false + ]); + + Fork::executeClosure(function () use ($wpRootFolder, $module) { + // Partial mocking the function that would load WordPress. + uopz_set_return(WPLoader::class, 'installAndBootstrapInstallation', function () { + return true; + }, true); + + $module->_initialize(); + + $this->assertInstanceOf(Configured::class, $module->getInstallation()->getState()); + $this->assertEquals($wpRootFolder . '/', $module->getWpRootFolder()); + $this->assertEquals($wpRootFolder . '/wp-content/', $module->getContentFolder()); + $this->assertEquals($wpRootFolder . '/wp-content/some-path', $module->getContentFolder('some-path')); + $this->assertEquals( + $wpRootFolder . '/wp-content/some/other/path/', + $module->getContentFolder('/some/other/path/') + ); + $this->assertEquals($wpRootFolder . '/wp-content/plugins/', $module->getPluginsFolder()); + $this->assertEquals($wpRootFolder . '/wp-content/mu-plugins/', $module->getMuPluginsFolder()); + }); + } + + public function testUsesCustomContentLocationFromConfigConstantInConfiguredInstallation(): void + { + [$wpRootFolder, $dbUrl] = $this->makeMockConfiguredInstallation(); + $contentDir = FS::tmpDir('custom-content-dir'); + $moduleContainer = new ModuleContainer(new Di(), []); + $module = new WPLoader($moduleContainer, [ + 'dbUrl' => $dbUrl, + 'wpRootFolder' => $wpRootFolder, + 'loadOnly' => false, + 'WP_CONTENT_DIR' => $contentDir + ]); + + Fork::executeClosure(function () use ($wpRootFolder, $contentDir, $module) { + // Partial mocking the function that would load WordPress. + uopz_set_return(WPLoader::class, 'installAndBootstrapInstallation', function () { + return true; + }, true); + + $module->_initialize(); + + $this->assertInstanceOf(Configured::class, $module->getInstallation()->getState()); + $this->assertEquals($wpRootFolder . '/', $module->getWpRootFolder()); + $this->assertEquals($contentDir . '/', $module->getContentFolder()); + $this->assertEquals($contentDir . '/some-path', $module->getContentFolder('some-path')); + $this->assertEquals($contentDir . '/some/other/path/', $module->getContentFolder('/some/other/path/')); + $this->assertEquals($contentDir . '/plugins/', $module->getPluginsFolder()); + $this->assertEquals($contentDir . '/mu-plugins/', $module->getMuPluginsFolder()); + }); + } + + public function testUsesCustomPluginsLocationFromConfigParameterInConfiguredInstallation(): void + { + [$wpRootFolder, $dbUrl] = $this->makeMockConfiguredInstallation(); + $pluginsDir = FS::tmpDir('custom-plugins-dir'); + $moduleContainer = new ModuleContainer(new Di(), []); + $module = new WPLoader($moduleContainer, [ + 'dbUrl' => $dbUrl, + 'wpRootFolder' => $wpRootFolder, + 'loadOnly' => false, + 'pluginsFolder' => $pluginsDir + ]); + + Fork::executeClosure(function () use ($wpRootFolder, $pluginsDir, $module) { + // Partial mocking the function that would load WordPress. + uopz_set_return(WPLoader::class, 'installAndBootstrapInstallation', function () { + return true; + }, true); + + $module->_initialize(); + + $this->assertInstanceOf(Configured::class, $module->getInstallation()->getState()); + $this->assertEquals($wpRootFolder . '/', $module->getWpRootFolder()); + $this->assertEquals($wpRootFolder . '/wp-content/', $module->getContentFolder()); + $this->assertEquals($pluginsDir . '/', $module->getPluginsFolder()); + $this->assertEquals($pluginsDir . '/some-path', $module->getPluginsFolder('some-path')); + $this->assertEquals($pluginsDir . '/some/other/path/', $module->getPluginsFolder('/some/other/path/')); + $this->assertEquals($wpRootFolder . '/wp-content/mu-plugins/', $module->getMuPluginsFolder()); + }); + } + + public function testUsesCustomPluginsLocationFromConfigConstantInConfiguredInstallation(): void + { + [$wpRootFolder, $dbUrl] = $this->makeMockConfiguredInstallation(); + $pluginsDir = FS::tmpDir('custom-plugins-dir'); + $pluginsDir2 = FS::tmpDir('custom-plugins-dir'); + $moduleContainer = new ModuleContainer(new Di(), []); + $module = new WPLoader($moduleContainer, [ + 'dbUrl' => $dbUrl, + 'wpRootFolder' => $wpRootFolder, + 'loadOnly' => false, + 'WP_PLUGIN_DIR' => $pluginsDir, + 'pluginsFolder' => $pluginsDir2 + ]); + + Fork::executeClosure(function () use ($wpRootFolder, $pluginsDir, $module) { + // Partial mocking the function that would load WordPress. + uopz_set_return(WPLoader::class, 'installAndBootstrapInstallation', function () { + return true; + }, true); + + $module->_initialize(); + + $this->assertInstanceOf(Configured::class, $module->getInstallation()->getState()); + $this->assertEquals($wpRootFolder . '/', $module->getWpRootFolder()); + $this->assertEquals($wpRootFolder . '/wp-content/', $module->getContentFolder()); + $this->assertEquals($pluginsDir . '/', $module->getPluginsFolder()); + $this->assertEquals($pluginsDir . '/some-path', $module->getPluginsFolder('some-path')); + $this->assertEquals($pluginsDir . '/some/other/path/', $module->getPluginsFolder('/some/other/path/')); + $this->assertEquals($wpRootFolder . '/wp-content/mu-plugins/', $module->getMuPluginsFolder()); + }); + } + + public function testUsesCustomMuPluginsLocationFromConfigConstantInConfiguredInstallation(): void + { + [$wpRootFolder, $dbUrl] = $this->makeMockConfiguredInstallation(); + $muPluginsDir = FS::tmpDir('custom-plugins-dir'); + $contentDir = FS::tmpDir('custom-content-dir'); + $moduleContainer = new ModuleContainer(new Di(), []); + $module = new WPLoader($moduleContainer, [ + 'dbUrl' => $dbUrl, + 'wpRootFolder' => $wpRootFolder, + 'loadOnly' => false, + 'WPMU_PLUGIN_DIR' => $muPluginsDir, + 'WP_CONTENT_DIR' => $contentDir + ]); + + Fork::executeClosure(function () use ($contentDir, $wpRootFolder, $muPluginsDir, $module) { + // Partial mocking the function that would load WordPress. + uopz_set_return(WPLoader::class, 'installAndBootstrapInstallation', function () { + return true; + }, true); + + $module->_initialize(); + + $this->assertInstanceOf(Configured::class, $module->getInstallation()->getState()); + $this->assertEquals($wpRootFolder . '/', $module->getWpRootFolder()); + $this->assertEquals($contentDir . '/', $module->getContentFolder()); + $this->assertEquals($contentDir . '/plugins/', $module->getPluginsFolder()); + $this->assertEquals($muPluginsDir . '/', $module->getMuPluginsFolder()); + $this->assertEquals($muPluginsDir . '/some-path', $module->getMuPluginsFolder('some-path')); + $this->assertEquals($muPluginsDir . '/some/other/path/', $module->getMuPluginsFolder('/some/other/path/')); + }); + } + + public function testUsesDefaultContentLocationInScaffoldedInstallation(): void + { + [$wpRootFolder, $dbUrl] = $this->makeMockScaffoldedInstallation(); + $moduleContainer = new ModuleContainer(new Di(), []); + $module = new WPLoader($moduleContainer, [ + 'dbUrl' => $dbUrl, + 'wpRootFolder' => $wpRootFolder, + 'loadOnly' => false + ]); + + Fork::executeClosure(function () use ($wpRootFolder, $module) { + // Partial mocking the function that would load WordPress. + uopz_set_return(WPLoader::class, 'installAndBootstrapInstallation', function () { + return true; + }, true); + + $module->_initialize(); + + $this->assertInstanceOf(Scaffolded::class, $module->getInstallation()->getState()); + $this->assertEquals($wpRootFolder . '/', $module->getWpRootFolder()); + $this->assertEquals($wpRootFolder . '/wp-content/', $module->getContentFolder()); + $this->assertEquals($wpRootFolder . '/wp-content/some-path', $module->getContentFolder('some-path')); + $this->assertEquals( + $wpRootFolder . '/wp-content/some/other/path/', + $module->getContentFolder('/some/other/path/') + ); + $this->assertEquals($wpRootFolder . '/wp-content/plugins/', $module->getPluginsFolder()); + $this->assertEquals($wpRootFolder . '/wp-content/mu-plugins/', $module->getMuPluginsFolder()); + }); + } + + public function testUsesCustomContentLocationFromConfigConstantInScaffoldedInstallation(): void + { + [$wpRootFolder, $dbUrl] = $this->makeMockScaffoldedInstallation(); + $contentDir = FS::tmpDir('custom-content-dir'); + $moduleContainer = new ModuleContainer(new Di(), []); + $module = new WPLoader($moduleContainer, [ + 'dbUrl' => $dbUrl, + 'wpRootFolder' => $wpRootFolder, + 'loadOnly' => false, + 'WP_CONTENT_DIR' => $contentDir + ]); + + Fork::executeClosure(function () use ($wpRootFolder, $module, $contentDir) { + // Partial mocking the function that would load WordPress. + uopz_set_return(WPLoader::class, 'installAndBootstrapInstallation', function () { + return true; + }, true); + + $module->_initialize(); + + $this->assertInstanceOf(Scaffolded::class, $module->getInstallation()->getState()); + $this->assertEquals($wpRootFolder . '/', $module->getWpRootFolder()); + $this->assertEquals($contentDir . '/', $module->getContentFolder()); + $this->assertEquals($contentDir . '/some-path', $module->getContentFolder('some-path')); + $this->assertEquals($contentDir . '/some/other/path/', $module->getContentFolder('/some/other/path/')); + $this->assertEquals($contentDir . '/plugins/', $module->getPluginsFolder()); + $this->assertEquals($contentDir . '/mu-plugins/', $module->getMuPluginsFolder()); + }); + } + + public function testUsesCustomPluginsLocationFromConfigParameterInScaffoldedInstallation(): void + { + [$wpRootFolder, $dbUrl] = $this->makeMockScaffoldedInstallation(); + $pluginsDir = FS::tmpDir('custom-plugins-dir'); + $moduleContainer = new ModuleContainer(new Di(), []); + $module = new WPLoader($moduleContainer, [ + 'dbUrl' => $dbUrl, + 'wpRootFolder' => $wpRootFolder, + 'loadOnly' => false, + 'pluginsFolder' => $pluginsDir + ]); + + Fork::executeClosure(function () use ($wpRootFolder, $module, $pluginsDir) { + // Partial mocking the function that would load WordPress. + uopz_set_return(WPLoader::class, 'installAndBootstrapInstallation', function () { + return true; + }, true); + + $module->_initialize(); + + $this->assertInstanceOf(Scaffolded::class, $module->getInstallation()->getState()); + $this->assertEquals($wpRootFolder . '/', $module->getWpRootFolder()); + $this->assertEquals($wpRootFolder . '/wp-content/', $module->getContentFolder()); + $this->assertEquals($pluginsDir . '/', $module->getPluginsFolder()); + $this->assertEquals($pluginsDir . '/some-path', $module->getPluginsFolder('some-path')); + $this->assertEquals($pluginsDir . '/some/other/path/', $module->getPluginsFolder('/some/other/path/')); + $this->assertEquals($wpRootFolder . '/wp-content/mu-plugins/', $module->getMuPluginsFolder()); + }); + } + + public function testUsesCustomPluginsLocationFromConfigConstantInScaffoldedInstallation(): void + { + [$wpRootFolder, $dbUrl] = $this->makeMockScaffoldedInstallation(); + $pluginsDir = FS::tmpDir('custom-plugins-dir'); + $pluginsDir2 = FS::tmpDir('custom-plugins-dir'); + $moduleContainer = new ModuleContainer(new Di(), []); + $module = new WPLoader($moduleContainer, [ + 'dbUrl' => $dbUrl, + 'wpRootFolder' => $wpRootFolder, + 'loadOnly' => false, + 'pluginsFolder' => $pluginsDir, + 'WP_PLUGIN_DIR' => $pluginsDir2 + ]); + + Fork::executeClosure(function () use ($wpRootFolder, $module, $pluginsDir2) { + // Partial mocking the function that would load WordPress. + uopz_set_return(WPLoader::class, 'installAndBootstrapInstallation', function () { + return true; + }, true); + + $module->_initialize(); + + $this->assertInstanceOf(Scaffolded::class, $module->getInstallation()->getState()); + $this->assertEquals($wpRootFolder . '/', $module->getWpRootFolder()); + $this->assertEquals($wpRootFolder . '/wp-content/', $module->getContentFolder()); + $this->assertEquals($pluginsDir2 . '/', $module->getPluginsFolder()); + $this->assertEquals($pluginsDir2 . '/some-path', $module->getPluginsFolder('some-path')); + $this->assertEquals($pluginsDir2 . '/some/other/path/', $module->getPluginsFolder('/some/other/path/')); + $this->assertEquals($wpRootFolder . '/wp-content/mu-plugins/', $module->getMuPluginsFolder()); + }); + } + + public function testUsesCustomMuPluginsLocationFromConfigConstantInScaffoldedInstallation(): void + { + [$wpRootFolder, $dbUrl] = $this->makeMockScaffoldedInstallation(); + $muPluginsDir = FS::tmpDir('custom-mu-plugins-dir'); + $moduleContainer = new ModuleContainer(new Di(), []); + $module = new WPLoader($moduleContainer, [ + 'dbUrl' => $dbUrl, + 'wpRootFolder' => $wpRootFolder, + 'loadOnly' => false, + 'WPMU_PLUGIN_DIR' => $muPluginsDir + ]); + + Fork::executeClosure(function () use ($wpRootFolder, $module, $muPluginsDir) { + // Partial mocking the function that would load WordPress. + uopz_set_return(WPLoader::class, 'installAndBootstrapInstallation', function () { + return true; + }, true); + + $module->_initialize(); + + $this->assertInstanceOf(Scaffolded::class, $module->getInstallation()->getState()); + $this->assertEquals($wpRootFolder . '/', $module->getWpRootFolder()); + $this->assertEquals($wpRootFolder . '/wp-content/', $module->getContentFolder()); + $this->assertEquals($wpRootFolder . '/wp-content/plugins/', $module->getPluginsFolder()); + $this->assertEquals($muPluginsDir . '/', $module->getMuPluginsFolder()); + $this->assertEquals($muPluginsDir . '/some-path', $module->getMuPluginsFolder('some-path')); + $this->assertEquals($muPluginsDir . '/some/other/path/', $module->getMuPluginsFolder('/some/other/path/')); + }); + } + + public function testThrowsIfContentDirConstantIsSetInWpConfigInConfiguredInstallation(): void{ + [$wpRootFolder, $dbUrl] = $this->makeMockConfiguredInstallation( + << $dbUrl, + 'wpRootFolder' => $wpRootFolder, + 'loadOnly' => false, + 'WP_CONTENT_DIR' => $contentDir + ]); + + Fork::executeClosure(function () use ($module) { + try { + $module->_initialize(); + } catch (\Throwable $e) { + $this->assertInstanceOf(ModuleConfigException::class, $e); + $this->assertStringContainsString( + 'Both the installation wp-config.php file and the module configuration define a WP_CONTENT_DIR constant: only one can be set.', + $e->getMessage() + ); + } + }); + } + + public function testThrowsIfPluginsDirConstantIsSetInWpConfigInConfiguredInstallation(): void{ + [$wpRootFolder, $dbUrl] = $this->makeMockConfiguredInstallation( + << $dbUrl, + 'wpRootFolder' => $wpRootFolder, + 'loadOnly' => false, + 'WP_PLUGIN_DIR' => $pluginsDir + ]); + + Fork::executeClosure(function () use ($module) { + try { + $module->_initialize(); + } catch (\Throwable $e) { + $this->assertInstanceOf(ModuleConfigException::class, $e); + $this->assertStringContainsString( + 'Both the installation wp-config.php file and the module configuration define a WP_PLUGIN_DIR constant: only one can be set.', + $e->getMessage() + ); + } + }); + } + + public function testThrowsIfMuPluginsDirConstantIsSetInWpConfigInConfiguredInstallation(): void{ + [$wpRootFolder, $dbUrl] = $this->makeMockConfiguredInstallation( + << $dbUrl, + 'wpRootFolder' => $wpRootFolder, + 'loadOnly' => false, + 'WPMU_PLUGIN_DIR' => $muPluginsDir + ]); + + Fork::executeClosure(function () use ($module) { + try { + $module->_initialize(); + } catch (\Throwable $e) { + $this->assertInstanceOf(ModuleConfigException::class, $e); + $this->assertStringContainsString( + 'Both the installation wp-config.php file and the module configuration define a WPMU_PLUGIN_DIR constant: only one can be set.', + $e->getMessage() + ); + } + }); + } +} diff --git a/tests/unit/lucatume/WPBrowser/Module/WPLoaderTest.php b/tests/unit/lucatume/WPBrowser/Module/WPLoaderTest.php index 88bdb64f0..f879f81d0 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; @@ -30,7 +31,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; @@ -40,6 +44,7 @@ /** * @group slow + * @group isolated-1 */ class WPLoaderTest extends Unit { @@ -127,15 +132,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 +153,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 * @@ -651,10 +656,9 @@ 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(); - - Dispatcher::dispatch(Events::SUITE_BEFORE); + $wpLoader->_loadWordPress(); }); } @@ -707,7 +711,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); @@ -797,7 +801,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); }); @@ -1551,7 +1555,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(); @@ -1751,7 +1755,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()); @@ -1923,8 +1927,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 +1946,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 +1983,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 +1995,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 +2010,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 +2029,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 +2043,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 +2061,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 +2075,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 +2095,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 +2109,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 +2128,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 +2146,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 +2169,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 +2187,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 +2228,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 +2241,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 +2259,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 +2280,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 +2294,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 +2316,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 +2330,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 +2357,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 +2371,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); diff --git a/tests/unit/lucatume/WPBrowser/Module/WPTestCaseStrictTest.php b/tests/unit/lucatume/WPBrowser/Module/WPTestCaseStrictTest.php index 132db89c6..1af2d27a5 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; diff --git a/tests/unit/lucatume/WPBrowser/Traits/UopzFunctionsTest.php b/tests/unit/lucatume/WPBrowser/Traits/UopzFunctionsTest.php index 3f67f4bbf..86e60b125 100644 --- a/tests/unit/lucatume/WPBrowser/Traits/UopzFunctionsTest.php +++ b/tests/unit/lucatume/WPBrowser/Traits/UopzFunctionsTest.php @@ -1298,4 +1298,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/ArrTest.php b/tests/unit/lucatume/WPBrowser/Utils/ArrTest.php index f39d03207..56ac792eb 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 ], ]; } @@ -64,29 +64,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 ] ]; } @@ -107,71 +107,71 @@ 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', function (string $value) : bool { return $value === '3'; }] ], 'array has mixed shape, 3rd type is Closure, misses one' => [ - 'array' => [new stdClass, 2], - 'expected' => false, + [new stdClass, 2], + false, ['stdClass', 'int', function (string $value) : bool { return $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' => function (string $value) : bool { return $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' => function (string $value) : bool { return $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' => function (string $value) : bool { return $value === '3'; }, 'a' => 'stdClass'] diff --git a/tests/unit/lucatume/WPBrowser/Utils/ChromedriverInstallerTest.php b/tests/unit/lucatume/WPBrowser/Utils/ChromedriverInstallerTest.php index 99fa2c4c5..53cb29837 100644 --- a/tests/unit/lucatume/WPBrowser/Utils/ChromedriverInstallerTest.php +++ b/tests/unit/lucatume/WPBrowser/Utils/ChromedriverInstallerTest.php @@ -276,6 +276,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); diff --git a/tests/unit/lucatume/WPBrowser/Utils/FilesystemTest.php b/tests/unit/lucatume/WPBrowser/Utils/FilesystemTest.php index f76c765d5..fe0b7cc8d 100644 --- a/tests/unit/lucatume/WPBrowser/Utils/FilesystemTest.php +++ b/tests/unit/lucatume/WPBrowser/Utils/FilesystemTest.php @@ -262,4 +262,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..51b747c91 --- /dev/null +++ b/tests/unit/lucatume/WPBrowser/Utils/MachineInformationTest.php @@ -0,0 +1,101 @@ +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', function ($arg) use ($uname) { + return $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', function ($arg) use ($uname) { + return $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()); + } +} diff --git a/tests/webdriver/PluginActivationCest.php b/tests/webdriver/PluginActivationCest.php index 6715d736e..ce2cdb485 100644 --- a/tests/webdriver/PluginActivationCest.php +++ b/tests/webdriver/PluginActivationCest.php @@ -71,22 +71,8 @@ public function be_able_to_activate_plugins_in_a_long_list(WebDriverTester $I): protected function scaffoldTestPlugins(WebDriverTester $I): void { - $template - = <<< HANDLEBARS -/* -Plugin Name: Plugin {{letter}} -Plugin URI: https://wordpress.org/plugins/{{letter}}/ -Description: Plugin {{letter}} description -Version: 0.1.0 -Author: Plugin {{letter}} author -Author URI: http://example.com/{{letter}}-plugin -Text Domain: {{letter}}_plugin -Domain Path: /languages -*/ -HANDLEBARS; - foreach (range('A', 'Z') as $letter) { - $compiled = str_replace('{{letter}}', $letter, $template); + $compiled = "function {$letter}_main(){}"; $I->havePlugin("plugin-{$letter}/plugin-{$letter}.php", $compiled); } } diff --git a/tests/wploadersuite/AttachmentCleanupTest.php b/tests/wploadersuite/AttachmentCleanupTest.php new file mode 100644 index 000000000..c3a9ad208 --- /dev/null +++ b/tests/wploadersuite/AttachmentCleanupTest.php @@ -0,0 +1,32 @@ +attachment->create_upload_object( + codecept_data_dir('attachments/kitten.jpeg') + ); + + $this->assertIsInt(self::$standaloneAttachment); + $this->assertTrue(file_exists(get_attached_file(self::$standaloneAttachment))); + self::$standaloneAttachmentFile = get_attached_file(self::$standaloneAttachment); + } + + public function testAttachmentsCreatedWithCreateUploadObjectAreDeleted(): void + { + $this->assertEquals('', get_attached_file(self::$standaloneAttachment)); + $this->assertFalse(file_exists(self::$standaloneAttachmentFile)); + } +}