diff --git a/Makefile b/Makefile index fea1505a0..2adb07c9f 100644 --- a/Makefile +++ b/Makefile @@ -46,7 +46,8 @@ wordpress_install: clean_procs: pgrep -f 'php -S' | xargs kill - pgrep chromedriver | xargs kill + -pkill -9 -f chromedriver + -pkill -9 -f mysqld rm -f var/_output/*.pid var/_output/*.running set -o allexport && source tests/.env && set +o allexport && docker compose down .PHONY: clean_procs diff --git a/codeception.dist.yml b/codeception.dist.yml index b1602a48e..21f89cc61 100644 --- a/codeception.dist.yml +++ b/codeception.dist.yml @@ -18,12 +18,6 @@ coverage: - src/* wpFolder: '%WORDPRESS_ROOT_DIR%' extensions: - enabled: - - "lucatume\\WPBrowser\\Extension\\EventDispatcherBridge" - - "lucatume\\WPBrowser\\Extension\\BuiltInServerController" - - "lucatume\\WPBrowser\\Extension\\ChromeDriverController" - - "lucatume\\WPBrowser\\Extension\\DockerComposeController" - - "lucatume\\WPBrowser\\Extension\\IsolationSupport" config: "lucatume\\WPBrowser\\Extension\\BuiltInServerController": docroot: '%WORDPRESS_ROOT_DIR%' @@ -35,6 +29,17 @@ extensions: "lucatume\\WPBrowser\\Extension\\DockerComposeController": compose-file: docker-compose.yml env-file: tests/.env + "lucatume\\WPBrowser\\Extension\\MysqlServerController": + port: '%WORDPRESS_DB_LOCALHOST_PORT%' + database: '%WORDPRESS_DB_NAME%' + user: '%WORDPRESS_DB_USER%' + password: '%WORDPRESS_DB_PASSWORD%' + enabled: + - "lucatume\\WPBrowser\\Extension\\EventDispatcherBridge" + - "lucatume\\WPBrowser\\Extension\\BuiltInServerController" + - "lucatume\\WPBrowser\\Extension\\ChromeDriverController" + - "lucatume\\WPBrowser\\Extension\\MysqlServerController" + - "lucatume\\WPBrowser\\Extension\\IsolationSupport" commands: - "lucatume\\WPBrowser\\Command\\RunOriginal" - "lucatume\\WPBrowser\\Command\\RunAll" diff --git a/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..fe59aa99d --- /dev/null +++ b/src/Extension/MysqlServerController.php @@ -0,0 +1,209 @@ +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(); + + $output->write("Starting MySQL server on port $port ..."); + try { + $this->mysqlServer = new MysqlServer( + codecept_output_dir('_mysql_server'), + $port, + $database, + $user, + $password, + $binary + ); + $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); + } +} diff --git a/src/ManagedProcess/ChromeDriver.php b/src/ManagedProcess/ChromeDriver.php index e4e174c84..5e29ca66d 100644 --- a/src/ManagedProcess/ChromeDriver.php +++ b/src/ManagedProcess/ChromeDriver.php @@ -44,7 +44,7 @@ public function __construct( /** * @throws RuntimeException */ - public function doStart(): void + private function doStart(): void { $command = [$this->chromeDriverBinary, '--port=' . $this->port, ...$this->arguments]; $process = new Process($command); diff --git a/src/ManagedProcess/ManagedProcessTrait.php b/src/ManagedProcess/ManagedProcessTrait.php index 4d2bddd93..bd06e3502 100644 --- a/src/ManagedProcess/ManagedProcessTrait.php +++ b/src/ManagedProcess/ManagedProcessTrait.php @@ -43,8 +43,9 @@ public function stop(): ?int $exitCode = $process->stop(); if (is_file(static::getPidFile()) && !unlink(static::getPidFile())) { + $pidFile = static::getPidFile(); throw new RuntimeException( - "Could not remove PID file '{static::getPidFile(}'.", + "Could not remove PID file {$pidFile}.", ManagedProcessInterface::ERR_PID_FILE_DELETE ); } diff --git a/src/ManagedProcess/MysqlServer.php b/src/ManagedProcess/MysqlServer.php new file mode 100644 index 000000000..5d67f775d --- /dev/null +++ b/src/ManagedProcess/MysqlServer.php @@ -0,0 +1,547 @@ +usingCustomBinary = true; + } + + if ($binary !== null && !is_executable($binary)) { + throw new RuntimeException( + "MySQL Server binary $binary does not exist.", + ManagedProcessInterface::ERR_BINARY_NOT_FOUND + ); + } + + if ($this->usingCustomBinary) { + if (!($shareDir && is_dir($shareDir))) { + throw new RuntimeException( + "MySQL Server share directory $shareDir does not exist.", + self::ERR_CUSTOM_BINARY_SHARE_DIR_PATH + ); + } + + $this->customShareDir = $shareDir; + } + + $this->directory = $directory ?? (FS::cacheDir() . '/mysql-server'); + if (!is_dir($this->directory) && !mkdir($this->directory, 0777, true) && !is_dir($this->directory)) { + throw new RuntimeException( + "Could not create directory for MySQL Server at $this->directory", + self::ERR_MYSQL_DIR_NOT_CREATED + ); + } + $this->binary = $binary; + $this->machineInformation = new MachineInformation(); + $this->pidFile = self::getPidFile(); + } + + public function setMachineInformation(MachineInformation $machineInformation): void + { + $this->machineInformation = $machineInformation; + } + + public function getDataDir(bool $normalize = false): string + { + $isWin = $this->machineInformation->isWindows(); + $dataDir = $this->directory . '/data'; + return $isWin && !$normalize ? + str_replace('/', '\\', $dataDir) + : $dataDir; + } + + public function getPidFilePath(bool $normalize = false): string + { + $isWin = $this->machineInformation->isWindows(); + return $isWin && !$normalize ? + str_replace('/', '\\', $this->pidFile) + : $this->pidFile; + } + + /** + * @return array + */ + private function getInitializeCommand(bool $normalize = false): array + { + $dataDir = $this->getDataDir($normalize); + return [ + $this->getBinary($normalize), + '--no-defaults', + '--initialize-insecure', + '--innodb-flush-method=nosync', + '--datadir=' . $dataDir, + '--pid-file=' . $this->getPidFilePath($normalize) + ]; + } + + public function initializeServer(): void + { + if (is_dir($this->getDataDir(true))) { + return; + } + + $this->output?->writeln("Initializing MySQL Server ...", OutputInterface::VERBOSITY_DEBUG); + $process = new Process($this->getInitializeCommand()); + $process->mustRun(); + $this->output?->writeln('MySQL Server initialized.', OutputInterface::VERBOSITY_DEBUG); + } + + public function getExtractedPath(bool $normalize = false): string + { + if ($this->usingCustomBinary) { + throw new RuntimeException( + "Extracted path not available when using a custom binary.", + MysqlServer::ERR_CUSTOM_BINARY_EXTRACTED_PATH + ); + } + + $mysqlServerArchivePath = $this->getArchivePath($normalize); + $isWin = $this->machineInformation->isWindows(); + $normalizedMysqlServerArchivePath = $isWin && !$normalize ? + str_replace('\\', '/', $mysqlServerArchivePath) + : $mysqlServerArchivePath; + $archiveExtension = match ($this->machineInformation->getOperatingSystem()) { + MachineInformation::OS_DARWIN => '.tar.gz', + MachineInformation::OS_WINDOWS => '.zip', + default => '.tar.xz', + }; + $extractedPath = dirname($normalizedMysqlServerArchivePath) . '/' . basename( + $normalizedMysqlServerArchivePath, + $archiveExtension + ); + + return !$normalize && $this->machineInformation->isWindows() ? + str_replace('/', '\\', $extractedPath) + : $extractedPath; + } + + public function getShareDir(bool $normalize = false): string + { + if ($this->customShareDir) { + return $normalize ? FS::normalizePath($this->customShareDir) : $this->customShareDir; + } + + $shareDir = $this->getExtractedPath(true) . '/share'; + return !$normalize && $this->machineInformation->isWindows() ? + str_replace('/', '\\', $shareDir) + : $shareDir; + } + + public function getSocketPath(bool $normalize = false): string + { + $path = $this->directory . '/mysql.sock'; + return !$normalize && $this->machineInformation->isWindows() ? + str_replace('/', '\\', $path) + : $path; + } + + /** + * @return array + */ + private function getStartCommand(int $port, bool $normalize = false): array + { + return [ + $this->getBinaryPath($normalize), + '--datadir=' . $this->getDataDir(), + '--skip-mysqlx', + '--default-time-zone=+00:00', + '--innodb-flush-method=nosync', + '--innodb-flush-log-at-trx-commit=0', + '--innodb-doublewrite=0', + '--bind-address=localhost', + '--lc-messages-dir=' . $this->getShareDir($normalize), + '--socket=' . $this->getSocketPath($normalize), + '--port=' . $port, + '--pid-file=' . $this->getPidFilePath($normalize) + ]; + } + + private function startServer(int $port): Process + { + $this->initializeServer(); + $dataDir = $this->getDataDir(true); + if (!is_dir($dataDir) && !(mkdir($dataDir, 0755, true) && is_dir($dataDir))) { + throw new RuntimeException( + "Could not create directory for MySQL Server data at $dataDir", + self::ERR_MYSQL_DATA_DIR_NOT_CREATED + ); + } + $startCommand = $this->getStartCommand($port); + $process = new Process($startCommand); + $process->createNewConsole(); + try { + // Try to start the server 40 times, 10 seconds apart. + $tries = 40; + $process->start(); + while (!$this->getRootPDOOrNot() && $tries--) { + // Sleep a .25 seconds to allow the server to start. + usleep(250000); + } + } catch (\Exception $e) { + throw new RuntimeException( + "Could not start MySQL Server at $this->directory\n" . $e->getMessage(), + self::ERR_MYSQL_SERVER_START_FAILED, + $e + ); + } + return $process; + } + + private function getRootPDOOrNot(): ?\PDO + { + try { + return $this->getRootPDO(); + } catch (\Throwable) { + return null; + } + } + + public function getRootPassword(): string + { + return $this->getUser() === 'root' ? $this->password : ''; + } + + /** + * @throws PDOException + */ + public function getRootPDO(): \PDO + { + try { + return new \PDO( + "mysql:host=127.0.0.1;port={$this->port}", + 'root', + $this->getRootPassword(), + [ + \PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION, + \PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC, + \PDO::ATTR_EMULATE_PREPARES => false, + ] + ); + } catch (\PDOException $e) { + // Connection with the set password failed, the server might not have been initialized yet + // and still use the default, insecure, empty root password. + return new \PDO( + "mysql:host=127.0.0.1;port={$this->port}", + 'root', + '', + [ + \PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION, + \PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC, + \PDO::ATTR_EMULATE_PREPARES => false, + ] + ); + } + } + + public function setDatabaseName(string $databaseName): void + { + $this->database = $databaseName; + } + + public function setUserName(string $username): void + { + $this->user = $username; + } + + public function setPassword(string $password): void + { + $this->password = $password; + } + + public function getDatabase(): string + { + return $this->database; + } + + public function getUser(): string + { + return $this->user; + } + + public function getPassword(): string + { + return $this->password; + } + + private function createDefaultData(): void + { + $pdo = $this->getRootPDO(); + $user = $this->getUser(); + $password = $this->getPassword(); + if ($user === 'root' && $password !== '') { + $pdo->exec("ALTER USER 'root'@'localhost' IDENTIFIED BY '{$this->getPassword()}'"); + } + $databaseName = $this->getDatabase(); + $pdo->exec("CREATE DATABASE IF NOT EXISTS `$databaseName`"); + if ($user !== 'root') { + $pdo->exec("CREATE USER IF NOT EXISTS '$user'@'%' IDENTIFIED BY '$password'"); + $pdo->exec("GRANT ALL PRIVILEGES ON `$databaseName`.* TO '$user'@'%'"); + } + $pdo->exec("FLUSH PRIVILEGES"); + } + + private function doStart(): void + { + $this->process = $this->startServer($this->port ?? self::PORT_DEFAULT); + $this->createDefaultData(); + } + + public function getBinaryPath(bool $normalize = false): string + { + if ($this->binary !== null) { + return !$normalize && $this->machineInformation->isWindows() ? + str_replace('/', '\\', $this->binary) + : $this->binary; + } + + $isWin = $this->machineInformation->isWindows(); + $binaryPath = implode('/', [ + $this->getExtractedPath(true), + 'bin', + ($isWin ? 'mysqld.exe' : 'mysqld') + ]); + + return !$normalize && $isWin ? + str_replace('/', '\\', $binaryPath) + : $binaryPath; + } + + public function getBinary(bool $normalize = false): string + { + if ($this->binary !== null) { + return !$normalize && $this->machineInformation->isWindows() ? + str_replace('/', '\\', $this->binary) + : $this->binary; + } + + $mysqlServerArchivePath = $this->getArchivePath(true); + $mysqlServerBinaryPath = $this->getBinaryPath(true); + + if (is_file($mysqlServerBinaryPath)) { + return !$normalize && $this->machineInformation->isWindows() ? + str_replace('/', '\\', $mysqlServerBinaryPath) + : $mysqlServerBinaryPath; + } + + if (!is_file($mysqlServerArchivePath)) { + $this->downloadMysqlServerArchive(); + } + + if (!is_file($mysqlServerBinaryPath)) { + $this->extractMysqlServerArchive(); + } + + if (!is_file($mysqlServerBinaryPath)) { + throw new RuntimeException( + "Could not find MySQL Server binary at $mysqlServerBinaryPath", + self::ERR_BINARY_NOT_FOUND + ); + } + + if (!$normalize && $this->machineInformation->isWindows()) { + $mysqlServerBinaryPath = str_replace('/', '\\', $mysqlServerBinaryPath); + } + + return $mysqlServerBinaryPath; + } + + public function getArchiveUrl(): string + { + $operatingSystem = $this->machineInformation->getOperatingSystem(); + if (!in_array($operatingSystem, [ + MachineInformation::OS_DARWIN, + MachineInformation::OS_LINUX, + MachineInformation::OS_WINDOWS + ], true)) { + throw new RuntimeException( + "Unsupported OS for MySQL Server binary.", + self::ERR_OS_NOT_SUPPORTED + ); + }; + + $architecture = $this->machineInformation->getArchitecture(); + if (!in_array($architecture, [MachineInformation::ARCH_X86_64, MachineInformation::ARCH_ARM64], true)) { + throw new RuntimeException( + "Unsupported architecture for MySQL Server binary.", + self::ERR_ARCH_NOT_SUPPORTED + ); + } + + if ($operatingSystem === MachineInformation::OS_WINDOWS && $architecture === MachineInformation::ARCH_ARM64) { + throw new RuntimeException( + "Windows ARM64 is not (yet) supported by MySQL Server.\n" . + "Use MySQL through the DockerComposeController extension.\n" . + "See: https://wpbrowser.wptestkit.dev/extensions/DockerComposeController/\n" . + "See: https://hub.docker.com/_/mysql", + self::ERR_WINDOWS_ARM64_NOT_SUPPORTED, + ); + } + + if ($operatingSystem === MachineInformation::OS_DARWIN) { + return $architecture === 'arm64' ? + 'https://dev.mysql.com/get/Downloads/MySQL-8.4/mysql-8.4.2-macos14-arm64.tar.gz' + : 'https://dev.mysql.com/get/Downloads/MySQL-8.4/mysql-8.4.2-macos14-x86_64.tar.gz'; + } + + if ($operatingSystem === MachineInformation::OS_LINUX) { + return $architecture === 'arm64' ? + 'https://dev.mysql.com/get/Downloads/MySQL-8.4/mysql-8.4.2-linux-glibc2.17-aarch64-minimal.tar.xz' + : 'https://dev.mysql.com/get/Downloads/MySQL-8.4/mysql-8.4.2-linux-glibc2.17-x86_64-minimal.tar.xz'; + } + + return 'https://dev.mysql.com/get/Downloads/MySQL-8.4/mysql-8.4.2-winx64.zip'; + } + + public function getArchivePath(bool $normalize = false): string + { + $path = $this->directory . '/' . basename($this->getArchiveUrl()); + return $this->machineInformation->isWindows() && !$normalize ? + str_replace('/', '\\', $path) + : $path; + } + + private function downloadMysqlServerArchive(): void + { + $archiveUrl = $this->getArchiveUrl(); + $archivePath = $this->getArchivePath(true); + + try { + $this->output?->writeln( + "Downloading MySQL Server archive from $archiveUrl ...", + OutputInterface::VERBOSITY_DEBUG + ); + Download::fileFromUrl($archiveUrl, $archivePath); + $this->output?->writeln('Downloaded MySQL Server archive.', OutputInterface::VERBOSITY_DEBUG); + } catch (\Exception $e) { + throw new RuntimeException( + "Could not download MySQL Server archive from $archiveUrl to $archivePath: " . $e->getMessage(), + self::ERR_MYSQL_ARCHIVE_DOWNLOAD_FAILED, + $e + ); + } + } + + /** + * @throws RuntimeException + */ + private function extractArchiveWithPhar(string $archivePath, string $directory): void + { + $memoryLimit = ini_set('memory_limit', '1G'); + try { + $extracted = (new PharData($archivePath))->extractTo($directory, null, true); + } catch (\Exception $e) { + throw new RuntimeException( + "Could not extract MySQL Server archive from $archivePath to " + . $directory . + "\n" . $e->getMessage(), + self::ERR_MYSQL_ARCHIVE_EXTRACTION_FAILED + ); + } finally { + ini_set('memory_limit', (string)$memoryLimit); + } + } + + /** + * @throws ProcessFailedException + */ + private function extractArchiveWithTarCommand(string $archivePath, string $directory): void + { + $extension = pathinfo($archivePath, PATHINFO_EXTENSION); + $flags = $extension === 'xz' ? '-xf' : '-xzf'; + $process = new Process(['tar', $flags, $archivePath, '-C', $directory]); + $process->mustRun(); + } + + private function extractMysqlServerArchive(): void + { + $mysqlServerArchivePath = $this->getArchivePath(true); + + $this->output?->writeln( + "Extracting MySQL Server archive from $mysqlServerArchivePath ...", + OutputInterface::VERBOSITY_DEBUG + ); + $directory = $this->directory; + try { + if ($this->machineInformation->isWindows()) { + $this->extractArchiveWithPhar($mysqlServerArchivePath, $directory); + } else { + $this->extractArchiveWithTarCommand($mysqlServerArchivePath, $directory); + } + } catch (\Throwable $e) { + throw new RuntimeException( + "Could not extract MySQL Server archive from $mysqlServerArchivePath to " + . $directory . + "\n" . $e->getMessage(), + self::ERR_MYSQL_ARCHIVE_EXTRACTION_FAILED + ); + } + $this->output?->writeln('Extracted MySQL Server archive.', OutputInterface::VERBOSITY_DEBUG); + } + + public function isUsingCustomBinary(): bool + { + return $this->usingCustomBinary; + } + + public function setOutput(OutputInterface $output = null): void + { + $this->output = $output; + } + + public function getDirectory(bool $normalize = false): string + { + return !$normalize && $this->machineInformation->isWindows() ? + str_replace('/', '\\', $this->directory) + : $this->directory; + } +} diff --git a/src/ManagedProcess/PhpBuiltInServer.php b/src/ManagedProcess/PhpBuiltInServer.php index 1f24f631c..14e39d9a8 100644 --- a/src/ManagedProcess/PhpBuiltInServer.php +++ b/src/ManagedProcess/PhpBuiltInServer.php @@ -47,7 +47,7 @@ public function __construct(private string $docRoot, private int $port = 0, priv /** * @throws RuntimeException */ - public function doStart(): void + private function doStart(): void { $routerPathname = dirname(__DIR__, 2) . '/includes/cli-server/router.php'; $command = [ diff --git a/src/Traits/UopzFunctions.php b/src/Traits/UopzFunctions.php index 4269bdf51..69da255da 100644 --- a/src/Traits/UopzFunctions.php +++ b/src/Traits/UopzFunctions.php @@ -63,7 +63,7 @@ trait UopzFunctions private static ?bool $uopzAllowExit = null; - protected function setFunctionReturn(string $function, mixed $value, bool $execute = false): void + protected function setFunctionReturn(string $function, mixed $value, bool $execute = false): Closure { if (!function_exists('uopz_set_return')) { $this->markTestSkipped('This test requires the uopz extension'); @@ -71,6 +71,10 @@ protected function setFunctionReturn(string $function, mixed $value, bool $execu uopz_set_return($function, $value, $execute); self::$uopzSetFunctionReturns[$function] = true; + + return function () use ($function) { + $this->unsetFunctionReturn($function); + }; } protected function unsetFunctionReturn(string $function): void @@ -83,11 +87,15 @@ protected function unsetFunctionReturn(string $function): void unset(self::$uopzSetFunctionReturns[$function]); } - protected function setMethodReturn(string $class, string $method, mixed $value, bool $execute = false): void + protected function setMethodReturn(string $class, string $method, mixed $value, bool $execute = false): Closure { $classAndMethod = "$class::$method"; uopz_set_return($class, $method, $value, $execute); self::$uopzSetFunctionReturns[$classAndMethod] = true; + + return function () use ($class, $method) { + $this->unsetMethodReturn($class, $method); + }; } protected function unsetMethodReturn(string $class, string $method): void @@ -102,7 +110,7 @@ protected function unsetMethodReturn(string $class, string $method): void unset(self::$uopzSetFunctionReturns[$classAndMethod]); } - protected function setFunctionHook(string $function, Closure $hook): void + protected function setFunctionHook(string $function, Closure $hook): Closure { if (!function_exists('uopz_set_hook')) { $this->markTestSkipped('This test requires the uopz extension'); @@ -110,6 +118,10 @@ protected function setFunctionHook(string $function, Closure $hook): void uopz_set_hook($function, $hook); self::$uopzSetFunctionHooks[$function] = true; + + return function () use ($function) { + $this->unsetFunctionHook($function); + }; } protected function unsetFunctionHook(string $function): void @@ -122,7 +134,7 @@ protected function unsetFunctionHook(string $function): void unset(self::$uopzSetFunctionHooks[$function]); } - protected function setMethodHook(string $class, string $method, Closure $hook): void + protected function setMethodHook(string $class, string $method, Closure $hook): Closure { if (!function_exists('uopz_set_hook')) { $this->markTestSkipped('This test requires the uopz extension'); @@ -131,6 +143,10 @@ protected function setMethodHook(string $class, string $method, Closure $hook): $classAndMethod = "$class::$method"; uopz_set_hook($class, $method, $hook); self::$uopzSetFunctionHooks[$classAndMethod] = true; + + return function () use ($class, $method) { + $this->unsetMethodHook($class, $method); + }; } protected function unsetMethodHook(string $class, string $method): void @@ -145,7 +161,7 @@ protected function unsetMethodHook(string $class, string $method): void unset(self::$uopzSetFunctionHooks[$classAndMethod]); } - protected function setConstant(string $constant, mixed $value): void + protected function setConstant(string $constant, mixed $value): Closure { if (!function_exists('uopz_redefine')) { $this->markTestSkipped('This test requires the uopz extension'); @@ -158,6 +174,10 @@ protected function setConstant(string $constant, mixed $value): void uopz_redefine($constant, $value); } self::$uopzSetConstants[$constant] = $previousValue; + + return function () use ($constant) { + $this->unsetConstant($constant); + }; } protected function unsetConstant(string $constant): void @@ -176,7 +196,7 @@ protected function unsetConstant(string $constant): void unset(self::$uopzSetConstants[$constant]); } - protected function setClassConstant(string $class, string $constant, mixed $value): void + protected function setClassConstant(string $class, string $constant, mixed $value): Closure { if (!function_exists('uopz_redefine')) { $this->markTestSkipped('This test requires the uopz extension'); @@ -187,6 +207,10 @@ protected function setClassConstant(string $class, string $constant, mixed $valu : '__NOT_PREVIOUSLY_DEFINED__'; uopz_redefine($class, $constant, $value); self::$uopzSetConstants["$class::$constant"] = $previousValue; + + return function () use ($class, $constant) { + $this->unsetClassConstant($class, $constant); + }; } protected function unsetClassConstant(string $class, string $constant): void @@ -205,7 +229,7 @@ protected function unsetClassConstant(string $class, string $constant): void unset(self::$uopzSetConstants["$class::$constant"]); } - protected function setClassMock(string $class, mixed $mock): void + protected function setClassMock(string $class, mixed $mock): Closure { if (!function_exists('uopz_set_mock')) { $this->markTestSkipped('This test requires the uopz extension'); @@ -213,6 +237,10 @@ protected function setClassMock(string $class, mixed $mock): void uopz_set_mock($class, $mock); self::$uopzSetClassMocks[$class] = true; + + return function () use ($class) { + $this->unsetClassMock($class); + }; } protected function unsetClassMock(string $class): void @@ -225,7 +253,7 @@ protected function unsetClassMock(string $class): void unset(self::$uopzSetClassMocks[$class]); } - protected function unsetClassFinalAttribute(string $class): void + protected function unsetClassFinalAttribute(string $class): Closure { if (!function_exists('uopz_unset_return')) { $this->markTestSkipped('This test requires the uopz extension'); @@ -234,6 +262,10 @@ protected function unsetClassFinalAttribute(string $class): void $flags = uopz_flags($class, ''); uopz_flags($class, '', $flags & ~ZEND_ACC_FINAL); self::$uopzUnsetClassFinalAttribute[$class] = true; + + return function () use ($class) { + $this->resetClassFinalAttribute($class); + }; } protected function resetClassFinalAttribute(string $class): void @@ -247,7 +279,7 @@ protected function resetClassFinalAttribute(string $class): void unset(self::$uopzUnsetClassFinalAttribute[$class]); } - protected function unsetMethodFinalAttribute(string $class, string $method): void + protected function unsetMethodFinalAttribute(string $class, string $method): Closure { if (!function_exists('uopz_unset_return')) { $this->markTestSkipped('This test requires the uopz extension'); @@ -256,6 +288,10 @@ protected function unsetMethodFinalAttribute(string $class, string $method): voi $flags = uopz_flags($class, $method); uopz_flags($class, $method, $flags & ~ZEND_ACC_FINAL); self::$uopzUnsetClassMethodFinalAttribute["$class::$method"] = true; + + return function () use ($class, $method) { + $this->resetMethodFinalAttribute($class, $method); + }; } protected function resetMethodFinalAttribute(string $class, string $method): void @@ -270,7 +306,7 @@ protected function resetMethodFinalAttribute(string $class, string $method): voi unset(self::$uopzUnsetClassMethodFinalAttribute[$classAndMethod]); } - protected function addClassMethod(string $class, string $method, Closure $closure, bool $static = false): void + protected function addClassMethod(string $class, string $method, Closure $closure, bool $static = false): Closure { if (!function_exists('uopz_add_function')) { $this->markTestSkipped('This test requires the uopz extension'); @@ -282,6 +318,10 @@ protected function addClassMethod(string $class, string $method, Closure $closur } uopz_add_function($class, $method, $closure, $flags); self::$uopzAddClassMethods["$class::$method"] = true; + + return function () use ($class, $method) { + $this->removeClassMethod($class, $method); + }; } protected function removeClassMethod(string $class, string $method): void @@ -299,7 +339,7 @@ protected function setObjectProperty( string|object $classOrObject, string $property, mixed $value - ): void { + ): Closure { if (!function_exists('uopz_set_property')) { $this->markTestSkipped('This test requires the uopz extension'); } @@ -308,6 +348,10 @@ protected function setObjectProperty( uopz_set_property($classOrObject, $property, $value); $id = is_string($classOrObject) ? $classOrObject : spl_object_hash($classOrObject); self::$uopzSetObjectProperties["$id::$property"] = [$previousValue, $classOrObject]; + + return function () use ($classOrObject, $property) { + $this->resetObjectProperty($classOrObject, $property); + }; } protected function getObjectProperty(string|object $classOrObject, string $property): mixed @@ -335,7 +379,7 @@ protected function resetObjectProperty(string|object $classOrObject, string $pro /** * @param array $values */ - protected function setMethodStaticVariables(string $class, string $method, array $values): void + protected function setMethodStaticVariables(string $class, string $method, array $values): Closure { if (!function_exists('uopz_set_static')) { $this->markTestSkipped('This test requires the uopz extension'); @@ -350,6 +394,10 @@ protected function setMethodStaticVariables(string $class, string $method, array } uopz_set_static($class, $method, $values); + + return function () use ($class, $method) { + $this->resetMethodStaticVariables($class, $method); + }; } /** @@ -398,7 +446,7 @@ protected function getFunctionStaticVariables(string $function): array /** * @param array $values */ - protected function setFunctionStaticVariables(string $function, array $values): void + protected function setFunctionStaticVariables(string $function, array $values): Closure { if (!function_exists('uopz_set_static')) { $this->markTestSkipped('This test requires the uopz extension'); @@ -413,6 +461,10 @@ protected function setFunctionStaticVariables(string $function, array $values): } uopz_set_static($function, array_merge($currentValues, $values)); + + return function () use ($function) { + $this->resetFunctionStaticVariables($function); + }; } protected function resetFunctionStaticVariables(string $function): void @@ -426,7 +478,7 @@ protected function resetFunctionStaticVariables(string $function): void unset(self::$uopzSetFunctionStaticVariables[$function]); } - protected function addFunction(string $function, Closure $handler): void + protected function addFunction(string $function, Closure $handler): Closure { if (!function_exists('uopz_add_function')) { $this->markTestSkipped('This test requires the uopz extension'); @@ -434,6 +486,10 @@ protected function addFunction(string $function, Closure $handler): void self::$uopzAddedFunctions[$function] = true; uopz_add_function($function, $handler); + + return function () use ($function) { + $this->removeFunction($function); + }; } protected function removeFunction(string $function): void diff --git a/src/Utils/Filesystem.php b/src/Utils/Filesystem.php index 185e6106e..79ddc167d 100644 --- a/src/Utils/Filesystem.php +++ b/src/Utils/Filesystem.php @@ -427,4 +427,58 @@ private static function symfonyFilesystem(): SymfonyFilesystem self::$symfonyFilesystem ??= new SymfonyFilesystem(); return self::$symfonyFilesystem; } + + /** + * Copy of `wp_is_stream` from `wp-includes/functions.php`. + */ + public static function isStream(string $path):bool + { + $scheme_separator = strpos($path, '://'); + + if (false === $scheme_separator) { + // $path isn't a stream. + return false; + } + + $stream = substr($path, 0, $scheme_separator); + + return in_array($stream, stream_get_wrappers(), true); + } + + /** + * Copy of `wp_normalize_path` from `wp-includes/functions.php`. + */ + public static function normalizePath(string $path):string + { + if ($path === '') { + return ''; + } + + /** @var non-empty-string $path */ + + $wrapper = ''; + + if (self::isStream($path)) { + list( $wrapper, $path ) = explode('://', $path, 2); + + $wrapper .= '://'; + } + + // Standardize all paths to use '/'. + $path = str_replace('\\', '/', $path); + + // Replace multiple slashes down to a singular, allowing for network shares having two slashes. + $path = preg_replace('|(?<=.)/+|', '/', $path); + + if (empty($path)) { + return (string)$path; + } + + // Windows paths should uppercase the drive letter. + if (':' === $path[1]) { + $path = ucfirst($path); + } + + return $wrapper . $path; + } } diff --git a/src/Utils/MachineInformation.php b/src/Utils/MachineInformation.php new file mode 100644 index 000000000..17a5e072e --- /dev/null +++ b/src/Utils/MachineInformation.php @@ -0,0 +1,49 @@ +operatingSystem = $operatingSystem ?? match (strtolower(substr(php_uname('s'), 0, 3))) { + 'dar' => self::OS_DARWIN, + 'lin' => self::OS_LINUX, + 'win' => self::OS_WINDOWS, + default => self::OS_UNKNOWN + }; + + $this->architecture = $architecture ?? match (strtolower(php_uname('m'))) { + 'x86_64', 'amd64' => self::ARCH_X86_64, + 'arm64', 'aarch64' => self::ARCH_ARM64, + default => self::ARCH_UNKNOWN + }; + } + + public function getOperatingSystem(): string + { + return $this->operatingSystem; + } + + public function getArchitecture(): string + { + return $this->architecture; + } + + public function isWindows():bool + { + return $this->operatingSystem === self::OS_WINDOWS; + } +} diff --git a/tests/_data/mysql-server/mock-archives/mysql-8.4.2-linux-glibc2.17-aarch64-minimal.tar.xz b/tests/_data/mysql-server/mock-archives/mysql-8.4.2-linux-glibc2.17-aarch64-minimal.tar.xz new file mode 100644 index 000000000..866de05f8 Binary files /dev/null and b/tests/_data/mysql-server/mock-archives/mysql-8.4.2-linux-glibc2.17-aarch64-minimal.tar.xz differ diff --git a/tests/_data/mysql-server/mock-archives/mysql-8.4.2-linux-glibc2.17-x86_64-minimal.tar.xz b/tests/_data/mysql-server/mock-archives/mysql-8.4.2-linux-glibc2.17-x86_64-minimal.tar.xz new file mode 100644 index 000000000..b90fa6029 Binary files /dev/null and b/tests/_data/mysql-server/mock-archives/mysql-8.4.2-linux-glibc2.17-x86_64-minimal.tar.xz differ 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 000000000..0982baf81 Binary files /dev/null and b/tests/_data/mysql-server/mock-archives/mysql-8.4.2-macos14-arm64.tar.gz differ 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 000000000..595e6c9d5 Binary files /dev/null and b/tests/_data/mysql-server/mock-archives/mysql-8.4.2-macos14-x86_64.tar.gz differ diff --git a/tests/_data/mysql-server/mock-archives/mysql-8.4.2-winx64.zip b/tests/_data/mysql-server/mock-archives/mysql-8.4.2-winx64.zip new file mode 100644 index 000000000..7f7d7c65e Binary files /dev/null and b/tests/_data/mysql-server/mock-archives/mysql-8.4.2-winx64.zip differ diff --git a/tests/_data/uopz-test/functions.php b/tests/_data/uopz-test/functions.php index 6356ad46b..bba42f8e4 100644 --- a/tests/_data/uopz-test/functions.php +++ b/tests/_data/uopz-test/functions.php @@ -32,6 +32,15 @@ function withStaticVariable(): int $counter += $step; return $oldValue; } + + function withStaticVariableTwo(): int + { + static $counter = 0; + static $step = 2; + $oldValue = $counter; + $counter += $step; + return $oldValue; + } } namespace lucatume\WPBrowser\Acme\Project { diff --git a/tests/unit/lucatume/WPBrowser/Extension/MysqlServerControllerTest.php b/tests/unit/lucatume/WPBrowser/Extension/MysqlServerControllerTest.php new file mode 100644 index 000000000..20c2471d0 --- /dev/null +++ b/tests/unit/lucatume/WPBrowser/Extension/MysqlServerControllerTest.php @@ -0,0 +1,592 @@ +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 + ], $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 + ], $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 + ], $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']; + $options = []; + $output = new BufferedOutput(); + $this->setFunctionReturn('is_executable', function (string $file): bool { + return $file === '/usr/bin/mysqld' ? true : is_executable($file); + }, 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' + ], $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 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 + ], $args); + }, + 'start' => null + ]) + ); + $controller = new MysqlServerController($config, $options); + $controller->start(new NullOutput()); + } + + public function testCatchesMysqlServerExceptionDuringStart(): void + { + $config = []; + $options = []; + $output = new BufferedOutput(); + $this->setClassMock( + MysqlServer::class, + $this->makeEmptyClass(MysqlServer::class, [ + 'start' => function () { + throw new \Exception('Something went wrong'); + } + ]) + ); + + $controller = new MysqlServerController($config, $options); + $this->expectException(ExtensionException::class); + $this->expectExceptionMessage('Error while starting MySQL server. Something went wrong'); + $controller->start($output); + } + + public function testWillNotRestartIfAlreadyRunning(): void + { + // Mock the PID file existence. + $pidFile = (new MysqlServerController([], []))->getPidFile(); + $this->setFunctionReturn('is_file', function (string $file) use ($pidFile): bool { + return $file === $pidFile ? true : is_file($file); + }, true); + $this->setClassMock(MysqlServer::class, $this->makeEmptyClass(MysqlServer::class, [ + '__construct' => function () { + throw new AssertionFailedError( + 'The MysqlServer constructor should not be called.' + ); + }, + ])); + + $controller = new MysqlServerController([], []); + $controller->start(new NullOutput); + } + + public function testGetPort(): void + { + $controller = new MysqlServerController([ + 'port' => 12345, + ], []); + + $this->assertEquals(12345, $controller->getPort()); + } + + public function testStopRunningMysqlServer(): void + { + $config = []; + $options = []; + $output = new BufferedOutput(); + $this->setClassMock( + MysqlServer::class, + $this->makeEmptyClass(MysqlServer::class, [ + 'start' => null + ]) + ); + $pidFile = (new MysqlServerController([], []))->getPidFile(); + $this->setFunctionReturn('file_get_contents', function (string $file) use ($pidFile): string|false { + if ($file === $pidFile) { + return '12345'; + } + return file_get_contents($file); + }, true); + $this->setFunctionReturn('exec', function (string $command): string|false { + if ($command !== 'kill 12345 2>&1 > /dev/null') { + throw new AssertionFailedError('Unexpected exec command call: ' . $command); + } + return ''; + }, true); + $this->setFunctionReturn('unlink', function (string $file) use ($pidFile): bool { + if ($file === $pidFile) { + return true; + } + return unlink($file); + }, true); + + $controller = new MysqlServerController($config, $options); + $controller->start($output); + $controller->stop($output); + $port = MysqlServer::PORT_DEFAULT; + $this->assertEquals( + "Starting MySQL server on port {$port} ... ok\nStopping MySQL server with PID 12345 ... ok\n", + $output->fetch() + ); + } + + public function testStopWhenPidFileDoesNotExist(): void + { + $config = []; + $options = []; + $output = new BufferedOutput(); + $this->setClassMock( + MysqlServer::class, + $this->makeEmptyClass(MysqlServer::class, [ + 'start' => null + ]) + ); + $pidFile = (new MysqlServerController([], []))->getPidFile(); + $this->setFunctionReturn('file_get_contents', function (string $file) use ($pidFile): string|false { + if ($file === $pidFile) { + return false; + } + return file_get_contents($file); + }, true); + $this->setFunctionReturn('exec', function (string $command): string|false { + throw new AssertionFailedError('Unexpected exec command call: ' . $command); + }, true); + $this->setFunctionReturn('unlink', function (string $file) use ($pidFile): bool { + throw new AssertionFailedError('Unexpected unlink call for file: ' . $file); + }, true); + + $controller = new MysqlServerController($config, $options); + $controller->start($output); + $controller->stop($output); + $port = MysqlServer::PORT_DEFAULT; + $this->assertEquals( + "Starting MySQL server on port {$port} ... ok\nMySQL server not running.\n", + $output->fetch() + ); + } + + public function testStopThrowsIfPidFileCannotBeUnlinked(): void + { + $config = []; + $options = []; + $output = new BufferedOutput(); + $this->setClassMock( + MysqlServer::class, + $this->makeEmptyClass(MysqlServer::class, [ + 'start' => null + ]) + ); + $pidFile = (new MysqlServerController([], []))->getPidFile(); + $this->setFunctionReturn('file_get_contents', function (string $file) use ($pidFile): string|false { + if ($file === $pidFile) { + return '12345'; + } + return file_get_contents($file); + }, true); + $this->setFunctionReturn('exec', function (string $command): string|false { + return ''; + }, true); + $this->setFunctionReturn('unlink', function (string $file) use ($pidFile): bool { + return false; + }, true); + + $controller = new MysqlServerController($config, $options); + $controller->start($output); + $this->expectException(ExtensionException::class); + $this->expectExceptionMessage("Could not delete PID file '$pidFile'."); + $controller->stop($output); + } + + public function testPrettyName(): void + { + $controller = new MysqlServerController([], []); + $this->assertEquals('MySQL Community Server', $controller->getPrettyName()); + } + + public function testGetInfoWithDefaults(): void + { + $controller = new MysqlServerController([], []); + $pidFile = $controller->getPidFile(); + $this->setFunctionReturn('is_file', function (string $file) use ($pidFile): bool { + return $file === $pidFile ? false : is_file($file); + }, true); + + $port = MysqlServer::PORT_DEFAULT; + $this->assertEquals([ + 'running' => 'no', + 'pidFile' => FS::relativePath(codecept_root_dir(), $controller->getPidFile()), + 'host' => '127.0.0.1', + 'port' => $port, + 'user' => 'wordpress', + 'password' => 'wordpress', + 'root user' => 'root', + 'root password' => '', + ], $controller->getInfo()); + + $this->setFunctionReturn('is_file', function (string $file) use ($pidFile): bool { + return $file === $pidFile ? true : is_file($file); + }, true); + + $this->assertEquals([ + 'running' => 'yes', + 'pidFile' => FS::relativePath(codecept_root_dir(), $controller->getPidFile()), + 'host' => '127.0.0.1', + 'port' => $port, + 'user' => 'wordpress', + 'password' => 'wordpress', + 'root user' => 'root', + 'root password' => '', + 'mysql command' => "mysql -h 127.0.0.1 -P {$port} -u wordpress -p 'wordpress'", + 'mysql root command' => "mysql -h 127.0.0.1 -P {$port} -u root" + ], $controller->getInfo()); + } + + public function testGetInfoWithCustomConfig(): void + { + $controller = new MysqlServerController([ + 'port' => 12345, + 'database' => 'test', + 'user' => 'luca', + 'password' => 'secret', + ], []); + $pidFile = $controller->getPidFile(); + $this->setFunctionReturn('is_file', function (string $file) use ($pidFile): bool { + return $file === $pidFile ? false : is_file($file); + }, true); + + $port = 12345; + $this->assertEquals([ + 'running' => 'no', + 'pidFile' => FS::relativePath(codecept_root_dir(), $controller->getPidFile()), + 'host' => '127.0.0.1', + 'port' => $port, + 'user' => 'luca', + 'password' => 'secret', + 'root user' => 'root', + 'root password' => '', + ], $controller->getInfo()); + + $this->setFunctionReturn('is_file', function (string $file) use ($pidFile): bool { + return $file === $pidFile ? true : is_file($file); + }, true); + $this->assertEquals([ + 'running' => 'yes', + 'pidFile' => FS::relativePath(codecept_root_dir(), $controller->getPidFile()), + 'host' => '127.0.0.1', + 'port' => $port, + 'user' => 'luca', + 'password' => 'secret', + 'root user' => 'root', + 'root password' => '', + 'mysql command' => "mysql -h 127.0.0.1 -P {$port} -u luca -p 'secret'", + 'mysql root command' => "mysql -h 127.0.0.1 -P {$port} -u root" + ], $controller->getInfo()); + } + + public function testGetInfoUsingRootUser(): void + { + $controller = new MysqlServerController([ + 'port' => 12345, + 'database' => 'test', + 'user' => 'root', + 'password' => 'secret', + ], []); + $pidFile = $controller->getPidFile(); + $this->setFunctionReturn('is_file', function (string $file) use ($pidFile): bool { + return $file === $pidFile ? false : is_file($file); + }, true); + + $port = 12345; + $this->assertEquals([ + 'running' => 'no', + 'pidFile' => FS::relativePath(codecept_root_dir(), $controller->getPidFile()), + 'host' => '127.0.0.1', + 'port' => $port, + 'user' => 'root', + 'password' => 'secret', + 'root user' => 'root', + 'root password' => 'secret', + ], $controller->getInfo()); + + $this->setFunctionReturn('is_file', function (string $file) use ($pidFile): bool { + return $file === $pidFile ? true : is_file($file); + }, true); + $this->assertEquals([ + 'running' => 'yes', + 'pidFile' => FS::relativePath(codecept_root_dir(), $controller->getPidFile()), + 'host' => '127.0.0.1', + 'port' => $port, + 'user' => 'root', + 'password' => 'secret', + 'root user' => 'root', + 'root password' => 'secret', + 'mysql command' => "mysql -h 127.0.0.1 -P {$port} -u root -p 'secret'", + 'mysql root command' => "mysql -h 127.0.0.1 -P {$port} -u root -p 'secret'" + ], $controller->getInfo()); + } +} diff --git a/tests/unit/lucatume/WPBrowser/ManagedProcess/MysqlServerTest.php b/tests/unit/lucatume/WPBrowser/ManagedProcess/MysqlServerTest.php new file mode 100644 index 000000000..3fdb721dd --- /dev/null +++ b/tests/unit/lucatume/WPBrowser/ManagedProcess/MysqlServerTest.php @@ -0,0 +1,946 @@ +setFunctionReturn('file_put_contents', function (string $file): void { + throw new AssertionFAiledError('Unexpected file_put_contents call for ' . $file); + }); + + $this->setClassMock(Process::class, $this->makeEmptyClass(Process::class, [ + '__construct' => function (array $command) { + throw new AssertionFAiledError('Unexpected Process::__construct call for ' . print_r($command, true)); + } + ])); + + $this->setClassMock(Download::class, $this->makeEmpty(Download::class, [ + 'fileFromUrl' => function (string $url, string $file): void { + throw new AssertionFAiledError("Unexpected Download::fileFromUrl call for URL $url and file $file"); + } + ])); + + $server = (new MysqlServer()); + $pidFile = $server->getPidFile(); + $this->setFunctionReturn('is_file', function (string $file) use ($pidFile): bool { + return $file === $pidFile ? false : is_file($file); + }, true); + + $directory = $server->getDirectory(); + $this->unsetMkdirFunctionReturn = $this->setFunctionReturn( + 'mkdir', + function (string $dir, ...$rest) use ($directory): bool { + if ($dir === $directory) { + return mkdir($dir, ...$rest); + } + + throw new AssertionFailedError('Unexpected mkdir call for directory ' . $dir); + }, + true + ); + } + + public function osAndArchDataProvider(): array + { + return [ + 'windows x86_64' => [ + MachineInformation::OS_WINDOWS, + MachineInformation::ARCH_X86_64, + FS::cacheDir() . '/mysql-server/mysql-8.4.2-winx64', + FS::cacheDir() . '/mysql-server/mysql-8.4.2-winx64/bin/mysqld.exe', + ], + 'linux x86_64' => [ + MachineInformation::OS_LINUX, + MachineInformation::ARCH_X86_64, + FS::cacheDir() . '/mysql-server/mysql-8.4.2-linux-glibc2.17-x86_64-minimal', + FS::cacheDir() . '/mysql-server/mysql-8.4.2-linux-glibc2.17-x86_64-minimal/bin/mysqld', + ], + 'linux arm64' => [ + MachineInformation::OS_LINUX, + MachineInformation::ARCH_ARM64, + FS::cacheDir() . '/mysql-server/mysql-8.4.2-linux-glibc2.17-aarch64-minimal', + FS::cacheDir() . '/mysql-server/mysql-8.4.2-linux-glibc2.17-aarch64-minimal/bin/mysqld', + ], + 'darwin x86_64' => [ + MachineInformation::OS_DARWIN, + MachineInformation::ARCH_X86_64, + FS::cacheDir() . '/mysql-server/mysql-8.4.2-macos14-x86_64', + FS::cacheDir() . '/mysql-server/mysql-8.4.2-macos14-x86_64/bin/mysqld', + ], + 'darwin arm64' => [ + MachineInformation::OS_DARWIN, + MachineInformation::ARCH_ARM64, + FS::cacheDir() . '/mysql-server/mysql-8.4.2-macos14-arm64', + FS::cacheDir() . '/mysql-server/mysql-8.4.2-macos14-arm64/bin/mysqld', + ] + ]; + } + + /** + * @dataProvider osAndArchDataProvider + */ + public + function testConstructorWithDefaults( + string $os, + string $arch, + string $expectedExtractedPath, + string $expectedBinaryPath + ): void { + $mysqlServer = new MysqlServer(); + $machineInformation = new MachineInformation($os, $arch); + $mysqlServer->setMachineInformation($machineInformation); + $directory = FS::cacheDir() . '/mysql-server'; + $notNormalizedDirectory = $machineInformation->isWindows() ? + str_replace('/', '\\', $directory) + : $directory; + $this->assertEquals($notNormalizedDirectory, $mysqlServer->getDirectory()); + $this->assertEquals($directory, $mysqlServer->getDirectory(true)); + $this->assertEquals(MysqlServer::PORT_DEFAULT, $mysqlServer->getPort()); + $this->assertEquals('wordpress', $mysqlServer->getDatabase()); + $this->assertEquals('wordpress', $mysqlServer->getUser()); + $this->assertEquals('wordpress', $mysqlServer->getPassword()); + $this->assertEquals('', $mysqlServer->getRootPassword()); + $notNormalizedBinaryPath = $machineInformation->isWindows() ? + str_replace('/', '\\', $expectedBinaryPath) + : $expectedBinaryPath; + $this->assertEquals($notNormalizedBinaryPath, $mysqlServer->getBinaryPath()); + $this->assertEquals($expectedBinaryPath, $mysqlServer->getBinaryPath(true)); + $pidFilePath = codecept_output_dir(MysqlServer::PID_FILE_NAME); + $notNormalizedPidFilePath = $machineInformation->isWindows() ? + str_replace('/', '\\', $pidFilePath) + : $pidFilePath; + $this->assertEquals($notNormalizedPidFilePath, $mysqlServer->getPidFilePath()); + $this->assertEquals($pidFilePath, $mysqlServer->getPidFilePath(true)); + $dataDir = FS::cacheDir() . '/mysql-server/data'; + $notNormalizedDataDir = $machineInformation->isWindows() ? + str_replace('/', '\\', $dataDir) + : $dataDir; + $this->assertEquals($notNormalizedDataDir, $mysqlServer->getDataDir()); + $this->assertEquals($dataDir, $mysqlServer->getDataDir(true)); + $notNormalizedExtractedPath = $machineInformation->isWindows() ? + str_replace('/', '\\', $expectedExtractedPath) + : $expectedExtractedPath; + $this->assertEquals($notNormalizedExtractedPath, $mysqlServer->getExtractedPath()); + $this->assertEquals($expectedExtractedPath, $mysqlServer->getExtractedPath(true)); + $shareDir = $expectedExtractedPath . '/share'; + $notNormalizedShareDir = $machineInformation->isWindows() ? + str_replace('/', '\\', $shareDir) + : $shareDir; + $this->assertEquals($notNormalizedShareDir, $mysqlServer->getShareDir()); + $this->assertEquals($shareDir, $mysqlServer->getShareDir(true)); + $this->assertFalse($mysqlServer->isUsingCustomBinary()); + $socketPath = $directory . '/mysql.sock'; + $notNormalizedSocketPath = $machineInformation->isWindows() ? + str_replace('/', '\\', $socketPath) + : $socketPath; + $this->assertEquals($notNormalizedSocketPath, $mysqlServer->getSocketPath()); + $this->assertEquals($socketPath, $mysqlServer->getSocketPath(true)); + } + + public function testConstructorCustomValues(): void + { + $mysqlServer = new MysqlServer(__DIR__, 2389, 'test', 'luca', 'secret'); + $this->assertEquals(2389, $mysqlServer->getPort()); + $this->assertEquals('test', $mysqlServer->getDatabase()); + $this->assertEquals('luca', $mysqlServer->getUser()); + $this->assertEquals('secret', $mysqlServer->getPassword()); + $this->assertEquals('', $mysqlServer->getRootPassword()); + } + + public function testConstructorWithRootUser(): void + { + $mysqlServer = new MysqlServer(__DIR__, 2389, 'test', 'root', 'secret'); + $this->assertEquals(2389, $mysqlServer->getPort()); + $this->assertEquals('test', $mysqlServer->getDatabase()); + $this->assertEquals('root', $mysqlServer->getUser()); + $this->assertEquals('secret', $mysqlServer->getPassword()); + $this->assertEquals('secret', $mysqlServer->getRootPassword()); + } + + public function testConstructorCreatesDirectoryIfNotExists(): void + { + ($this->unsetMkdirFunctionReturn)(); + $dir = FS::tmpDir('mysql-server_'); + $mysqlServer = new MysqlServer($dir); + $this->assertDirectoryExists($dir); + } + + /** + * @dataProvider osAndArchDataProvider + */ + public function testConstructorWithCustomBinary(string $os, string $arch): void + { + $this->setFunctionReturn('is_executable', function (string $file): bool { + return $file === '/usr/bin/mysqld' ? true : is_executable($file); + }, true); + $this->setFunctionReturn('is_dir', function (string $dir): bool { + return $dir === '/some/share/dir' ? true : is_dir($dir); + }, true); + $mysqlServer = new MysqlServer( + __DIR__, + 2389, + 'test', + 'root', + 'secret', + '/usr/bin/mysqld', + '/some/share/dir' + ); + $machineInformation = new MachineInformation($os, $arch); + $mysqlServer->setMachineInformation($machineInformation); + $directory = __DIR__; + $notNormalizedDirectory = $machineInformation->isWindows() ? + str_replace('/', '\\', $directory) + : $directory; + $this->assertEquals($notNormalizedDirectory, $mysqlServer->getDirectory()); + $this->assertEquals($directory, $mysqlServer->getDirectory(true)); + $this->assertEquals(2389, $mysqlServer->getPort()); + $this->assertEquals('test', $mysqlServer->getDatabase()); + $this->assertEquals('root', $mysqlServer->getUser()); + $this->assertEquals('secret', $mysqlServer->getPassword()); + $this->assertEquals('secret', $mysqlServer->getRootPassword()); + $this->assertTrue($mysqlServer->isUsingCustomBinary()); + $notNormalizedBinaryPath = $machineInformation->isWindows() ? '\\usr\\bin\\mysqld' : '/usr/bin/mysqld'; + $this->assertEquals($notNormalizedBinaryPath, $mysqlServer->getBinaryPath()); + $this->assertEquals('/usr/bin/mysqld', $mysqlServer->getBinaryPath(true)); + $dataDir = __DIR__ . '/data'; + $notNormalizedDataDir = $machineInformation->isWindows() ? + str_replace('/', '\\', $dataDir) + : $dataDir; + $this->assertEquals($notNormalizedDataDir, $mysqlServer->getDataDir()); + $this->assertEquals($dataDir, $mysqlServer->getDataDir(true)); + } + + public function testGetExtractedPathThrowsForCustomBinary(): void + { + $this->setFunctionReturn('is_executable', function (string $file): bool { + return $file === '/usr/bin/mysqld' ? true : is_executable($file); + }, true); + $this->setFunctionReturn('is_dir', function (string $dir): bool { + return $dir === '/some/share/dir' ? true : is_dir($dir); + }, true); + $mysqlServer = new MysqlServer( + __DIR__, + 2389, + 'test', + 'root', + 'secret', + '/usr/bin/mysqld', + '/some/share/dir' + ); + $this->expectException(RuntimeException::class); + $this->expectExceptionCode(MysqlServer::ERR_CUSTOM_BINARY_EXTRACTED_PATH); + $mysqlServer->getExtractedPath(); + } + + public function testConstructorThrowsIfShareDirNotSetForCustomBinary(): void + { + $this->setFunctionReturn('is_executable', function (string $file): bool { + return $file === '/usr/bin/mysqld' ? true : is_executable($file); + }, true); + $this->setFunctionReturn('is_dir', function (string $dir): bool { + return $dir === '/some/share/dir' ? true : is_dir($dir); + }, true); + $this->expectException(RuntimeException::class); + $this->expectExceptionCode(MysqlServer::ERR_CUSTOM_BINARY_SHARE_DIR_PATH); + $mysqlServer = new MysqlServer( + __DIR__, + 2389, + 'test', + 'root', + 'secret', + '/usr/bin/mysqld' + ); + } + + public function testGetShareDireForCustomBinaryAndSetCustomShareDir(): void + { + $this->setFunctionReturn('is_executable', function (string $file): bool { + return $file === '/usr/bin/mysqld' ? true : is_executable($file); + }, true); + $this->setFunctionReturn('is_dir', function (string $dir): bool { + return $dir === '/some/share/dir' ? true : is_dir($dir); + }, true); + $mysqlServer = new MysqlServer( + __DIR__, + 2389, + 'test', + 'root', + 'secret', + '/usr/bin/mysqld', + '/some/share/dir' + ); + $shareDir = $mysqlServer->getShareDir(); + $this->assertEquals('/some/share/dir', $shareDir); + } + + public function testConstructorThrowsIfDirectoryCannotBeCreated(): void + { + $this->setFunctionReturn('mkdir', function (string $dir, ...$rest): bool { + return false; + }, true); + $this->expectException(RuntimeException::class); + $this->expectExceptionCode(MysqlServer::ERR_MYSQL_DIR_NOT_CREATED); + new MysqlServer('/my-data-dir'); + } + + public function startWithCustomParametersDataProvider(): Generator + { + foreach ($this->osAndArchDataProvider() as [$os, $arch]) { + yield "{$os}_{$arch}_default_parameters" => [ + $os, + $arch, + [], + [ + 'CREATE DATABASE IF NOT EXISTS `wordpress`', + "CREATE USER IF NOT EXISTS 'wordpress'@'%' IDENTIFIED BY 'wordpress'", + "GRANT ALL PRIVILEGES ON `wordpress`.* TO 'wordpress'@'%'", + 'FLUSH PRIVILEGES', + ] + ]; + + yield "{$os}_{$arch}_custom_parameters" => [ + $os, + $arch, + [ + 12345, + 'someDatabase', + 'someUser', + 'password' + ], + [ + 'CREATE DATABASE IF NOT EXISTS `someDatabase`', + "CREATE USER IF NOT EXISTS 'someUser'@'%' IDENTIFIED BY 'password'", + "GRANT ALL PRIVILEGES ON `someDatabase`.* TO 'someUser'@'%'", + 'FLUSH PRIVILEGES', + ] + ]; + } + } + + /** + * @dataProvider startWithCustomParametersDataProvider + */ + public function testStartAndStop(string $os, string $arch, array $params, array $expectedQueries): void + { + ($this->unsetMkdirFunctionReturn)(); + $dir = FS::tmpDir('mysql-server_'); + $mysqlServer = new MysqlServer($dir, ...$params); + $machineInformation = new MachineInformation($os, $arch); + $mysqlServer->setMachineInformation($machineInformation); + + // Mock the download of the archive. + $this->setMethodReturn( + Download::class, + 'fileFromUrl', + function (string $url, string $file) use ($mysqlServer): void { + Assert::assertEquals($mysqlServer->getArchiveUrl(), $url); + Assert::assertEquals($mysqlServer->getArchivePath(true), $file); + $archiveBasename = basename($mysqlServer->getArchiveUrl()); + copy(codecept_data_dir('mysql-server/mock-archives/' . $archiveBasename), $file); + }, + true + ); + + // Mock the extraction of the archive on Windows. + if ($machineInformation->isWindows()) { + $this->setClassMock( + PharData::class, + $this->makeEmptyClass(PharData::class, [ + 'extractTo' => function (string $directory, ?array $files = null, bool $overwrite = false) use ( + $mysqlServer + ): bool { + Assert::assertEquals($mysqlServer->getDirectory(true), $directory); + Assert::assertNull($files); + Assert::assertTrue($overwrite); + $extractedPath = $mysqlServer->getExtractedPath(true); + mkdir($extractedPath . '/share', 0777, true); + mkdir($extractedPath . '/bin', 0777, true); + touch($extractedPath . '/bin/mysqld.exe'); + chmod($extractedPath . '/bin/mysqld.exe', 0777); + return true; + }, + ]) + ); + } + + // Mock the processes to initialize and start the server. + $mockProcessStep = $machineInformation->isWindows() ? 'init' : 'extract'; + $this->setClassMock( + Process::class, + $this->makeEmptyClass( + Process::class, + [ + '__construct' => function (array $command) use (&$mockProcessStep, $mysqlServer) { + $archivePath = $mysqlServer->getArchivePath(); + $extension = pathinfo($archivePath, PATHINFO_EXTENSION); + $tarFlags = $extension === 'xz' ? '-xf' : '-xzf'; + if ($mockProcessStep === 'extract') { + Assert::assertEquals([ + 'tar', + $tarFlags, + $mysqlServer->getArchivePath(), + '-C', + $mysqlServer->getDirectory(), + ], $command); + $mockProcessStep = 'init'; + $extractedPath = $mysqlServer->getExtractedPath(true); + mkdir($extractedPath . '/share', 0777, true); + mkdir($extractedPath . '/bin', 0777, true); + touch($extractedPath . '/bin/mysqld'); + chmod($extractedPath . '/bin/mysqld', 0777); + return; + } + + if ($mockProcessStep === 'init') { + Assert::assertEquals([ + $mysqlServer->getBinaryPath(), + '--no-defaults', + '--initialize-insecure', + '--innodb-flush-method=nosync', + '--datadir=' . $mysqlServer->getDataDir(), + '--pid-file=' . $mysqlServer->getPidFilePath(), + ], $command); + $mockProcessStep = 'start'; + return; + } + + if ($mockProcessStep === 'start') { + Assert::assertEquals([ + $mysqlServer->getBinaryPath(), + '--datadir=' . $mysqlServer->getDataDir(), + '--skip-mysqlx', + '--default-time-zone=+00:00', + '--innodb-flush-method=nosync', + '--innodb-flush-log-at-trx-commit=0', + '--innodb-doublewrite=0', + '--bind-address=localhost', + '--lc-messages-dir=' . $mysqlServer->getShareDir(), + '--socket=' . $mysqlServer->getSocketPath(), + '--port=' . $mysqlServer->getPort(), + '--pid-file=' . $mysqlServer->getPidFilePath() + ], $command); + $mockProcessStep = 'started'; + return; + } + + throw new AssertionFailedError( + 'Unexpected Process::__construct call for ' . print_r($command, true) + ); + }, + 'mustRun' => '__itself', + 'isRunning' => function () use (&$mockProcessStep): bool { + return $mockProcessStep === 'started'; + }, + 'getPid' => 2389, + 'stop' => function () use (&$mockProcessStep): int { + Assert::assertTrue(in_array($mockProcessStep, ['started', 'stopped'], true)); + $mockProcessStep = 'stopped'; + return 0; + } + ] + ) + ); + + // Mock the PDO connection. + $queries = []; + $this->setClassMock(PDO::class, $this->makeEmptyClass(PDO::class, [ + '__construct' => function ( + string $dsn, + string $user, + string $password + ) use ($mysqlServer): void { + Assert::assertEquals('mysql:host=127.0.0.1;port=' . $mysqlServer->getPort(), $dsn); + Assert::assertEquals('root', $user); + Assert::assertEquals($mysqlServer->getRootPassword(), $password); + }, + 'exec' => function (string $query) use (&$queries): int|false { + $queries[] = $query; + return 1; + } + ])); + + // Mock the PID file write. + $pidFile = MysqlServer::getPidFile(); + $this->setFunctionReturn('file_put_contents', function (string $file, $pid) use ($pidFile): bool { + Assert::assertEquals($pidFile, $file); + Assert::assertEquals(2389, $pid); + return true; + }, true); + $this->setFunctionReturn('file_get_contents', function (string $file) use ($pidFile): bool { + Assert::assertEquals($pidFile, $file); + return 2389; + }, true); + + $mysqlServer->start(); + + $this->assertEquals($expectedQueries, $queries); + + $mysqlServer->stop(); + } + + /** + * @dataProvider osAndArchDataProvider + */ + public function testStartWithRootUser(string $os, string $arch): void + { + ($this->unsetMkdirFunctionReturn)(); + $dir = FS::tmpDir('mysql-server_'); + $mysqlServer = new MysqlServer($dir, 12345, 'someDatabase', 'root', 'secret'); + $machineInformation = new MachineInformation($os, $arch); + $mysqlServer->setMachineInformation($machineInformation); + + // Mock the download of the archive. + $this->setMethodReturn( + Download::class, + 'fileFromUrl', + function (string $url, string $file) use ($mysqlServer): void { + Assert::assertEquals($mysqlServer->getArchiveUrl(), $url); + Assert::assertEquals($mysqlServer->getArchivePath(true), $file); + $archiveBasename = basename($mysqlServer->getArchiveUrl()); + copy(codecept_data_dir('mysql-server/mock-archives/' . $archiveBasename), $file); + }, + true + ); + + // Mock the extraction of the archive on Windows. + if ($machineInformation->isWindows()) { + $this->setClassMock( + PharData::class, + $this->makeEmptyClass(PharData::class, [ + 'extractTo' => function (string $directory, ?array $files = null, bool $overwrite = false) use ( + $mysqlServer + ): bool { + Assert::assertEquals($mysqlServer->getDirectory(true), $directory); + Assert::assertNull($files); + Assert::assertTrue($overwrite); + $extractedPath = $mysqlServer->getExtractedPath(true); + mkdir($extractedPath . '/share', 0777, true); + mkdir($extractedPath . '/bin', 0777, true); + touch($extractedPath . '/bin/mysqld.exe'); + chmod($extractedPath . '/bin/mysqld.exe', 0777); + return true; + }, + ]) + ); + } + + // Mock the processes to initialize and start the server. + $mockProcessStep = $machineInformation->isWindows() ? 'init' : 'extract'; + $this->setClassMock( + Process::class, + $this->makeEmptyClass( + Process::class, + [ + '__construct' => function (array $command) use (&$mockProcessStep, $mysqlServer) { + $archivePath = $mysqlServer->getArchivePath(); + $extension = pathinfo($archivePath, PATHINFO_EXTENSION); + $tarFlags = $extension === 'xz' ? '-xf' : '-xzf'; + if ($mockProcessStep === 'extract') { + Assert::assertEquals([ + 'tar', + $tarFlags, + $mysqlServer->getArchivePath(), + '-C', + $mysqlServer->getDirectory(), + ], $command); + $mockProcessStep = 'init'; + $extractedPath = $mysqlServer->getExtractedPath(true); + mkdir($extractedPath . '/share', 0777, true); + mkdir($extractedPath . '/bin', 0777, true); + touch($extractedPath . '/bin/mysqld'); + chmod($extractedPath . '/bin/mysqld', 0777); + return; + } + + if ($mockProcessStep === 'init') { + Assert::assertEquals([ + $mysqlServer->getBinaryPath(), + '--no-defaults', + '--initialize-insecure', + '--innodb-flush-method=nosync', + '--datadir=' . $mysqlServer->getDataDir(), + '--pid-file=' . $mysqlServer->getPidFilePath(), + ], $command); + $mockProcessStep = 'start'; + return; + } + + if ($mockProcessStep === 'start') { + Assert::assertEquals([ + $mysqlServer->getBinaryPath(), + '--datadir=' . $mysqlServer->getDataDir(), + '--skip-mysqlx', + '--default-time-zone=+00:00', + '--innodb-flush-method=nosync', + '--innodb-flush-log-at-trx-commit=0', + '--innodb-doublewrite=0', + '--bind-address=localhost', + '--lc-messages-dir=' . $mysqlServer->getShareDir(), + '--socket=' . $mysqlServer->getSocketPath(), + '--port=' . $mysqlServer->getPort(), + '--pid-file=' . $mysqlServer->getPidFilePath() + ], $command); + $mockProcessStep = 'started'; + return; + } + + throw new AssertionFailedError( + 'Unexpected Process::__construct call for ' . print_r($command, true) + ); + }, + 'mustRun' => '__itself', + 'isRunning' => function () use (&$mockProcessStep): bool { + return $mockProcessStep === 'started'; + }, + 'getPid' => 2389 + ] + ) + ); + + // Mock the PDO connection. + $queries = []; + $calls = 0; + $this->setClassMock(PDO::class, $this->makeEmptyClass(PDO::class, [ + '__construct' => function ( + string $dsn, + string $user, + string $password + ) use ($mysqlServer, &$calls): void { + if ($calls === 0) { + // The first call with the not-yet set root password will fail. + Assert::assertEquals('mysql:host=127.0.0.1;port=' . $mysqlServer->getPort(), $dsn); + Assert::assertEquals('root', $user); + Assert::assertEquals($mysqlServer->getRootPassword(), $password); + ++$calls; + throw new \PDOException('Error'); + } + + if ($calls === 1) { + // Second call is done with the empty root password. + Assert::assertEquals('mysql:host=127.0.0.1;port=' . $mysqlServer->getPort(), $dsn); + Assert::assertEquals('root', $user); + Assert::assertEquals('', $password); + ++$calls; + } else { + // Further calls should be done with the now set correct root password. + Assert::assertEquals('mysql:host=127.0.0.1;port=' . $mysqlServer->getPort(), $dsn); + Assert::assertEquals('root', $user); + Assert::assertEquals($mysqlServer->getRootPassword(), $password); + ++$calls; + } + }, + 'exec' => function (string $query) use (&$queries): int|false { + $queries[] = $query; + return 1; + } + ])); + + // Mock the PID file write. + $pidFile = MysqlServer::getPidFile(); + $this->setFunctionReturn('file_put_contents', function (string $file, $pid) use ($pidFile): bool { + Assert::assertEquals($pidFile, $file); + Assert::assertEquals(2389, $pid); + return true; + }, true); + $this->setFunctionReturn('file_get_contents', function (string $file) use ($pidFile): bool { + Assert::assertEquals($pidFile, $file); + return 2389; + }, true); + + $mysqlServer->start(); + + $this->assertEquals( + [ + "ALTER USER 'root'@'localhost' IDENTIFIED BY 'secret'", + 'CREATE DATABASE IF NOT EXISTS `someDatabase`', + 'FLUSH PRIVILEGES', + ], + $queries + ); + } + + /** + * @dataProvider osAndArchDataProvider + */ + public function testStartServerWithCustomBinary(string $os, string $arch): void + { + ($this->unsetMkdirFunctionReturn)(); + $dir = FS::tmpDir('mysql-server_'); + $machineInformation = new MachineInformation($os, $arch); + + // The custom binary exists and is executable. + if ($machineInformation->isWindows()) { + $this->setFunctionReturn('is_executable', function (string $file): bool { + return $file === 'C:/usr/bin/mysqld.exe' ? true : is_executable($file); + }, true); + } else { + $this->setFunctionReturn('is_executable', function (string $file): bool { + return $file === '/usr/bin/mysqld' ? true : is_executable($file); + }, true); + } + + // The custom share directory exists. + if ($machineInformation->isWindows()) { + $this->setFunctionReturn('is_dir', function (string $dir): bool { + return $dir === 'C:\\some\\share\\dir' ? true : is_dir($dir); + }, true); + } else { + $this->setFunctionReturn('is_dir', function (string $dir): bool { + return $dir === '/some/share/dir' ? true : is_dir($dir); + }, true); + } + + $mysqlServer = new MysqlServer( + $dir, + 12345, + 'someDatabase', + 'someUser', + 'password', + $machineInformation->isWindows() ? 'C:\\usr\\bin\\mysqld.exe' : '/usr/bin/mysqld', + $machineInformation->isWindows() ? 'C:\\some\\share\\dir' : '/some/share/dir' + ); + $mysqlServer->setMachineInformation($machineInformation); + + // Mock the download of the archive. + $this->setMethodReturn( + Download::class, + 'fileFromUrl', + function (string $url, string $file) use ($mysqlServer): void { + throw new AssertionFailedError('No file should be downloaded.'); + }, + true + ); + + // Mock the extraction of the archive on Windows. + if ($machineInformation->isWindows()) { + $this->setClassMock( + PharData::class, + $this->makeEmptyClass(PharData::class, [ + 'extractTo' => fn() => throw new AssertionFailedError( + 'No extraction should be performed on Windows.' + ) + ]) + ); + } + + // Mock the processes to initialize and start the server. + $mockProcessStep = 'init'; + $this->setClassMock( + Process::class, + $this->makeEmptyClass( + Process::class, + [ + '__construct' => function (array $command) use (&$mockProcessStep, $mysqlServer) { + if ($mockProcessStep === 'init') { + Assert::assertEquals([ + $mysqlServer->getBinaryPath(), + '--no-defaults', + '--initialize-insecure', + '--innodb-flush-method=nosync', + '--datadir=' . $mysqlServer->getDataDir(), + '--pid-file=' . $mysqlServer->getPidFilePath(), + ], $command); + $mockProcessStep = 'start'; + return; + } + + if ($mockProcessStep === 'start') { + Assert::assertEquals([ + $mysqlServer->getBinaryPath(), + '--datadir=' . $mysqlServer->getDataDir(), + '--skip-mysqlx', + '--default-time-zone=+00:00', + '--innodb-flush-method=nosync', + '--innodb-flush-log-at-trx-commit=0', + '--innodb-doublewrite=0', + '--bind-address=localhost', + '--lc-messages-dir=' . $mysqlServer->getShareDir(), + '--socket=' . $mysqlServer->getSocketPath(), + '--port=' . $mysqlServer->getPort(), + '--pid-file=' . $mysqlServer->getPidFilePath() + ], $command); + $mockProcessStep = 'started'; + return; + } + + throw new AssertionFailedError( + 'Unexpected Process::__construct call for ' . print_r($command, true) + ); + }, + 'mustRun' => '__itself', + 'isRunning' => function () use (&$mockProcessStep): bool { + return $mockProcessStep === 'started'; + }, + 'getPid' => 2389 + ] + ) + ); + + // Mock the PDO connection. + $queries = []; + $this->setClassMock(PDO::class, $this->makeEmptyClass(PDO::class, [ + '__construct' => function ( + string $dsn, + string $user, + string $password + ) use ($mysqlServer): void { + Assert::assertEquals('mysql:host=127.0.0.1;port=' . $mysqlServer->getPort(), $dsn); + Assert::assertEquals('root', $user); + Assert::assertEquals($mysqlServer->getRootPassword(), $password); + }, + 'exec' => function (string $query) use (&$queries): int|false { + $queries[] = $query; + return 1; + } + ])); + + // Mock the PID file write. + $pidFile = MysqlServer::getPidFile(); + $this->setFunctionReturn('file_put_contents', function (string $file, $pid) use ($pidFile): bool { + Assert::assertEquals($pidFile, $file); + Assert::assertEquals(2389, $pid); + return true; + }, true); + $this->setFunctionReturn('file_get_contents', function (string $file) use ($pidFile): bool { + Assert::assertEquals($pidFile, $file); + return 2389; + }, true); + + $mysqlServer->start(); + + $this->assertEquals( + [ + 'CREATE DATABASE IF NOT EXISTS `someDatabase`', + "CREATE USER IF NOT EXISTS 'someUser'@'%' IDENTIFIED BY 'password'", + "GRANT ALL PRIVILEGES ON `someDatabase`.* TO 'someUser'@'%'", + 'FLUSH PRIVILEGES', + ], + $queries + ); + } + + /** + * @dataProvider osAndArchDataProvider + */ + public function testStartWhenAlreadyRunning(string $os, string $arch): void + { + $pidFile = MysqlServer::getPidFile(); + + // The PID file exists. + $this->setFunctionReturn('is_file', function (string $file) use ($pidFile): bool { + return $file === $pidFile ? true : is_file($file); + }, true); + + $this->setClassMock(Process::class, $this->makeEmptyClass(Process::class, [ + '__construct' => fn() => throw new AssertionFailedError('No process should be started.'), + ])); + + $this->setClassMock(PDO::class, $this->makeEmptyClass(PDO::class, [ + '__construct' => fn() => throw new AssertionFailedError('No PDO connection should be made.'), + ])); + + $machineInformation = new MachineInformation($os, $arch); + $mysqlServer = new MysqlServer(__DIR__); + $mysqlServer->setMachineInformation($machineInformation); + } + + public function testStopThrowsIfNotRunning(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('MySQL Server not started.'); + + $mysqlServer = new MysqlServer(__DIR__); + $mysqlServer->stop(); + } + + public function testStopThrowsIfPidFileCannotBeUnlinked(): void + { + ($this->unsetMkdirFunctionReturn)(); + $dir = FS::tmpDir('mysql-server_'); + // The custom binary exists and is executable. + $this->setFunctionReturn('is_executable', function (string $file): bool { + return $file === '/usr/bin/mysqld' ? true : is_executable($file); + }, true); + + // The custom share directory exists. + $this->setFunctionReturn('is_dir', function (string $dir): bool { + return $dir === '/some/share/dir' ? true : is_dir($dir); + }, true); + + // Mock the processes to initialize and start the server. + $this->setClassMock( + Process::class, + $this->makeEmptyClass( + Process::class, + [ + 'mustRun' => '__itself', + 'getPid' => 2389, + 'stop' => 0, + 'isRunning' => true, + ] + ) + ); + + $pidFile = MysqlServer::getPidFile(); + + // Mock the PID file write. + $pidFileExists = false; + $this->setFunctionReturn('file_put_contents', function (string $file, $pid) use ($pidFile,&$pidFileExists): bool { + Assert::assertEquals($pidFile, $file); + Assert::assertEquals(2389, $pid); + $pidFileExists = true; + return true; + }, true); + + // The PID file exists. + $this->setFunctionReturn('is_file', function (string $file) use (&$pidFileExists, $pidFile): bool { + return $file === $pidFile ? $pidFileExists : is_file($file); + }, true); + + // The PID file cannot be unlinked. + $unlinked = false; + $this->setFunctionReturn('unlink', function (string $file) use (&$pidFile): bool { + return $file === $pidFile ? false : unlink($file); + }, true); + + // Mock the PDO constructor. + $pdoConstructorCalledWithCorrectArgs = false; + $this->setClassMock(PDO::class, $this->makeEmptyClass(PDO::class, [ + 'exec' => 1 + ])); + + $mysqlServer = new MysqlServer($dir, 12345, 'someDatabase', 'root', 'secret','/usr/bin/mysqld', '/some/share/dir'); + $mysqlServer->start(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage("Could not remove PID file {$pidFile}."); + + $mysqlServer->stop(); + } +} diff --git a/tests/unit/lucatume/WPBrowser/Traits/UopzFunctionsTest.php b/tests/unit/lucatume/WPBrowser/Traits/UopzFunctionsTest.php index 7b1c9e35a..8bc20513d 100644 --- a/tests/unit/lucatume/WPBrowser/Traits/UopzFunctionsTest.php +++ b/tests/unit/lucatume/WPBrowser/Traits/UopzFunctionsTest.php @@ -1282,4 +1282,323 @@ public function should_restore_exit_between_tests(): void { $this->assertEquals(1, ini_get('uopz.exit')); } + + public function testSetFunctionReturnReturnsUnsetClosure(): void + { + $unsetReturn = $this->setFunctionReturn('someTestFunction', 23); + + $this->assertEquals(23, someTestFunction()); + + $unsetReturn(); + + $this->assertEquals('test-test-test', someTestFunction()); + } + + public function testSetInstanceMethodReturnReturnsUnsetClosure(): void + { + $unsetReturn = $this->setMethodReturn(SomeGlobalClassOne::class, 'getValueOne', 23); + + $this->assertEquals(23, (new SomeGlobalClassOne())->getValueOne()); + + $unsetReturn(); + + $this->assertEquals('original-value-one', (new SomeGlobalClassOne())->getValueOne()); + } + + public function testSetStaticMethodReturnReturnsUnsetClosure(): void + { + $unsetReturn = $this->setMethodReturn(SomeGlobalClassOne::class, 'getStaticValueOne', 23); + + $this->assertEquals(23, SomeGlobalClassOne::getStaticValueOne()); + + $unsetReturn(); + + $this->assertEquals('original-static-value-one', SomeGlobalClassOne::getStaticValueOne()); + } + + public function testSetFunctionHookReturnsUnsetClosure(): void + { + $headers = []; + $hook = function (string $header, bool $replace = true, int $response_code = 0) use ( + &$headers + ): void { + $headers[] = [ + 'header' => $header, + 'replace' => $replace, + 'response_code' => $response_code, + ]; + }; + + $headers = []; + $unsetFunctionHook = $this->setFunctionHook('header', $hook); + + header('Location: http://example.com', true, 301); + + $this->assertEquals([ + [ + 'header' => 'Location: http://example.com', + 'replace' => true, + 'response_code' => 301, + ], + ], $headers); + + $unsetFunctionHook(); + + header('X-Test: hello', true); + + $this->assertEquals([ + [ + 'header' => 'Location: http://example.com', + 'replace' => true, + 'response_code' => 301, + ], + ], $headers); + } + + public function testSetInstanceMethodHookReturnsUnsetClosure(): void + { + $gotten = 0; + $unsetMethodHook = $this->setMethodHook(SomeGlobalClassOne::class, 'getValueOne', function () use (&$gotten) { + $gotten++; + }); + $someGlobalClassOne = new SomeGlobalClassOne(); + $someGlobalClassOne->getValueOne(); + $someGlobalClassOne->getValueOne(); + $someGlobalClassOne->getValueOne(); + + $this->assertEquals(3, $gotten); + + $unsetMethodHook(); + + $someGlobalClassOne->getValueOne(); + $someGlobalClassOne->getValueOne(); + $someGlobalClassOne->getValueOne(); + + $this->assertEquals(3, $gotten); + } + + public function testSetStaticMethodHookReturnsUnsetClosure(): void + { + $gotten = 0; + $unsetMethodHook = $this->setMethodHook( + SomeGlobalClassOne::class, + 'getStaticValueOne', + function () use (&$gotten): void { + $gotten++; + } + ); + + SomeGlobalClassOne::getStaticValueOne(); + SomeGlobalClassOne::getStaticValueOne(); + SomeGlobalClassOne::getStaticValueOne(); + + $this->assertEquals(3, $gotten); + + $unsetMethodHook(); + + SomeGlobalClassOne::getStaticValueOne(); + SomeGlobalClassOne::getStaticValueOne(); + SomeGlobalClassOne::getStaticValueOne(); + + $this->assertEquals(3, $gotten); + } + + public function testSetConstantUnsetClosure(): void + { + $unsetConstant = $this->setConstant('EXISTING_CONSTANT', 'hello'); + + $this->assertEquals('hello', EXISTING_CONSTANT); + + $unsetConstant(); + + $this->assertEquals('test-constant', EXISTING_CONSTANT); + } + + public function testSetClassConstantUnsetClosure(): void + { + $unsetClassConstant = $this->setClassConstant(SomeGlobalClassOne::class, 'EXISTING_CONSTANT', 'hello'); + + $this->assertEquals('hello', SomeGlobalClassOne::EXISTING_CONSTANT); + + $unsetClassConstant(); + + $this->assertEquals('test-constant', SomeGlobalClassOne::EXISTING_CONSTANT); + } + + public function testSetClassMockUnsetClosure(): void + { + $mockSomeGlobalClassOne = new class extends SomeGlobalClassOne { + public function getValueOne(): string + { + return 'mocked-value-one'; + } + }; + + $unsetClassMock = $this->setClassMock(SomeGlobalClassOne::class, $mockSomeGlobalClassOne); + + $mockSomeGlobalClassOneInstanceOne = new SomeGlobalClassOne(); + $mockSomeGlobalClassOneInstanceTwo = new SomeGlobalClassOne(); + + $this->assertSame($mockSomeGlobalClassOne, $mockSomeGlobalClassOneInstanceOne); + $this->assertSame($mockSomeGlobalClassOne, $mockSomeGlobalClassOneInstanceTwo); + $this->assertEquals('mocked-value-one', $mockSomeGlobalClassOneInstanceOne->getValueOne()); + $this->assertEquals('mocked-value-one', $mockSomeGlobalClassOneInstanceTwo->getValueOne()); + + $unsetClassMock(); + + $mockSomeGlobalClassOneInstanceOne = new SomeGlobalClassOne(); + $mockSomeGlobalClassOneInstanceTwo = new SomeGlobalClassOne(); + + $this->assertNotSame($mockSomeGlobalClassOne, $mockSomeGlobalClassOneInstanceOne); + $this->assertNotSame($mockSomeGlobalClassOne, $mockSomeGlobalClassOneInstanceTwo); + $this->assertEquals('original-value-one', $mockSomeGlobalClassOneInstanceOne->getValueOne()); + $this->assertEquals('original-value-one', $mockSomeGlobalClassOneInstanceTwo->getValueOne()); + } + + public function testUnsetClassFinalAttributeUnsetClosure(): void + { + $unsetClassFinalAttribute = $this->unsetClassFinalAttribute(SomeGlobalFinalClass::class); + + $globalExtension = new class extends SomeGlobalFinalClass { + public function someMethod(): int + { + return 89; + } + }; + + $this->assertEquals(89, $globalExtension->someMethod()); + + $unsetClassFinalAttribute(); + + $this->assertTrue((new ReflectionClass(SomeGlobalFinalClass::class))->isFinal()); + } + + public function testUnsetMethodFinalAttributeUnsetClosure(): void + { + $unsetMethodFinalAttribute = $this->unsetMethodFinalAttribute( + SomeGlobalClassWithFinalMethods::class, + 'someFinalMethod' + ); + + $globalExtension = new class extends SomeGlobalClassWithFinalMethods { + public function someFinalMethod(): int + { + return 123; + } + }; + + $this->assertEquals(123, $globalExtension->someFinalMethod()); + + $unsetMethodFinalAttribute(); + + $this->assertTrue((new ReflectionMethod(SomeGlobalClassWithFinalMethods::class, 'someFinalMethod'))->isFinal()); + } + + public function testAddClassMethodUnsetClosure(): void + { + $unsetAddClassInstanceMethod = $this->addClassMethod( + SomeGlobalClassWithoutMethods::class, + 'testInstanceMethod', + function (): int { + return $this->number; + } + ); + $unsetAddClassStaticMethod = $this->addClassMethod( + SomeGlobalClassWithoutMethods::class, + 'testStaticMethod', + function (): string { + return self::$name; + }, + true + ); + + $this->assertTrue(method_exists(SomeGlobalClassWithoutMethods::class, 'testInstanceMethod')); + $this->assertTrue(method_exists(SomeGlobalClassWithoutMethods::class, 'testStaticMethod')); + + $someGlobalClassWithoutMethods = new SomeGlobalClassWithoutMethods(); + $someGlobalClassWithoutMethods->testInstanceMethod(); + $someGlobalClassWithoutMethods->testStaticMethod(); + + $unsetAddClassInstanceMethod(); + + $this->assertFalse(method_exists(SomeGlobalClassWithoutMethods::class, 'testInstanceMethod')); + $this->assertTrue(method_exists(SomeGlobalClassWithoutMethods::class, 'testStaticMethod')); + } + + public function testSetObjectPropertyUnsetClosure(): void + { + $someNamespacedClassWithoutMethods = new SomeNamespacedClassWithoutMethods(); + $resetSetObjectProperty = $this->setObjectProperty($someNamespacedClassWithoutMethods, 'number', 89); + $resetStaticSetObjectProperty = $this->setObjectProperty( + SomeNamespacedClassWithoutMethods::class, + 'name', + 'Bob' + ); + + $this->assertEquals(89, $this->getObjectProperty($someNamespacedClassWithoutMethods, 'number')); + $this->assertEquals('Bob', $this->getObjectProperty(SomeNamespacedClassWithoutMethods::class, 'name')); + + $resetSetObjectProperty(); + $resetStaticSetObjectProperty(); + + $this->assertEquals(23, $this->getObjectProperty($someNamespacedClassWithoutMethods, 'number')); + $this->assertEquals('Luca', $this->getObjectProperty(SomeNamespacedClassWithoutMethods::class, 'name')); + } + + public function testSetMethodStaticVariablesUnsetClosure(): void + { + $someNamespacedClassWithStaticVariables = new NamespacedClassWithStaticVariables(); + $resetSetMethodStaticVariables = $this->setMethodStaticVariables( + NamespacedClassWithStaticVariables::class, + 'theCounter', + ['counter' => 23] + ); + $resetStaticSetMethodStaticVariables = $this->setMethodStaticVariables( + NamespacedClassWithStaticVariables::class, + 'theStaticCounter', + ['counter' => 89] + ); + + $this->assertEquals(['counter' => 23], + $this->getMethodStaticVariables(NamespacedClassWithStaticVariables::class, 'theCounter')); + $this->assertEquals(['counter' => 89], + $this->getMethodStaticVariables(NamespacedClassWithStaticVariables::class, 'theStaticCounter')); + + $resetSetMethodStaticVariables(); + $resetStaticSetMethodStaticVariables(); + + $this->assertEquals(['counter' => 0], + $this->getMethodStaticVariables(NamespacedClassWithStaticVariables::class, 'theCounter')); + $this->assertEquals(['counter' => 0], + $this->getMethodStaticVariables(NamespacedClassWithStaticVariables::class, 'theStaticCounter')); + } + + public function testSetFunctionStaticVariablesUnsetClosure(): void + { + $resetFunctionStaticVariables = $this->setFunctionStaticVariables( + 'withStaticVariableTwo', + ['counter' => 23, 'step' => 89] + ); + + $this->assertEquals(['counter' => 23, 'step' => 89], + $this->getFunctionStaticVariables('withStaticVariableTwo')); + + $resetFunctionStaticVariables(); + + $this->assertEquals(['counter' => 0, 'step' => 2], $this->getFunctionStaticVariables('withStaticVariableTwo')); + } + + public function testAddFunctionRemoveClosure(): void + { + $removeFunction = $this->addFunction('someTestFunctionOfMine', function (): int { + return 23; + }); + + $this->assertTrue(function_exists('someTestFunctionOfMine')); + $this->assertEquals(23, someTestFunctionOfMine()); + + $removeFunction(); + + $this->assertFalse(function_exists('someTestFunctionOfMine')); + } } diff --git a/tests/unit/lucatume/WPBrowser/Utils/FilesystemTest.php b/tests/unit/lucatume/WPBrowser/Utils/FilesystemTest.php index 05053fa5e..9ba768174 100644 --- a/tests/unit/lucatume/WPBrowser/Utils/FilesystemTest.php +++ b/tests/unit/lucatume/WPBrowser/Utils/FilesystemTest.php @@ -256,4 +256,25 @@ public function test_relativePath(\Closure $fixture): void $fullRelPath = $from . '/' . $expected; $this->assertFileExists(str_replace('\\', '/', $fullRelPath)); } + + public static function normalizePathDataProvider(): array + { + return [ + ['/foo/bar/baz', '/foo/bar/baz'], + ['C:\\foo\\bar\\baz', 'C:/foo/bar/baz'], + ['C:/foo/bar/baz', 'C:/foo/bar/baz'], + ['file:///foo/bar/baz', 'file:///foo/bar/baz'], + ['file://C:/foo/bar/baz', 'file://C:/foo/bar/baz'], + ['file://C:\\foo\\bar\\baz', 'file://C:/foo/bar/baz'], + ['c:\\foo\\bar/baz', 'C:/foo/bar/baz'], + ]; + } + + /** + * @dataProvider normalizePathDataProvider + */ + public function testNormalizePath(string $path, string $expected): void + { + $this->assertEquals($expected, Filesystem::normalizePath($path)); + } } diff --git a/tests/unit/lucatume/WPBrowser/Utils/MachineInformationTest.php b/tests/unit/lucatume/WPBrowser/Utils/MachineInformationTest.php new file mode 100644 index 000000000..456287e54 --- /dev/null +++ b/tests/unit/lucatume/WPBrowser/Utils/MachineInformationTest.php @@ -0,0 +1,97 @@ +assertEquals($os, $machineInformation->getOperatingSystem()); + $this->assertEquals($arch, $machineInformation->getArchitecture()); + } + + public function testGetOperatingSystemDataProvider(): array + { + return [ + ['Linux', MachineInformation::OS_LINUX], + ['Windows', MachineInformation::OS_WINDOWS], + ['Darwin', MachineInformation::OS_DARWIN], + ['Unknown', MachineInformation::OS_UNKNOWN], + ]; + } + + /** + * @dataProvider testGetOperatingSystemDataProvider + */ + public function testGetOperatingSystem(string $uname, string $expected): void + { + $this->setFunctionReturn('php_uname', fn($arg) => $arg === 's' ? $uname : php_uname($arg), true); + + $machineInformation = new MachineInformation(); + + $this->assertEquals($expected, $machineInformation->getOperatingSystem()); + } + + public function testGetArchitectureDataProvider(): array + { + return [ + ['x86_64', MachineInformation::ARCH_X86_64], + ['amd64', MachineInformation::ARCH_X86_64], + ['arm64', MachineInformation::ARCH_ARM64], + ['aarch64', MachineInformation::ARCH_ARM64], + ['Unknown', MachineInformation::ARCH_UNKNOWN], + ]; + } + + /** + * @dataProvider testGetArchitectureDataProvider + */ + public function testGetArchitecture(string $uname, string $expected): void + { + $this->setFunctionReturn('php_uname', fn($arg) => $arg === 'm' ? $uname : php_uname($arg), true); + + $machineInformation = new MachineInformation(); + + $this->assertEquals($expected, $machineInformation->getArchitecture()); + } + + public function testIsWindows(): void + { + $mockUname = 'linux'; + $this->setFunctionReturn('php_uname', function ($arg) use (&$mockUname) { + return $arg === 's' ? $mockUname : php_uname($arg); + }, true); + + $machineInformation = new MachineInformation(); + + $this->assertFalse($machineInformation->isWindows()); + + $mockUname = 'windows'; + $machineInformation = new MachineInformation(); + + $this->assertTrue($machineInformation->isWindows()); + } +}