diff --git a/CHANGELOG.md b/CHANGELOG.md index ed3a40744..485abbfb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ This project adheres to [Semantic Versioning](http://semver.org/). ## [unreleased] Unreleased +### Fixed + +- Search more paths for Chrome binaries on linux (#694, thanks to @iateadonut) + +### Changed + +- List paths searched for Chrome on current platform in debug output + ## [4.0.20] 2024-02-12; ### Added diff --git a/src/Utils/ChromedriverInstaller.php b/src/Utils/ChromedriverInstaller.php index db5171798..5e6ff088e 100644 --- a/src/Utils/ChromedriverInstaller.php +++ b/src/Utils/ChromedriverInstaller.php @@ -7,29 +7,28 @@ use lucatume\WPBrowser\Adapters\Symfony\Component\Process\Process; use lucatume\WPBrowser\Exceptions\InvalidArgumentException; use lucatume\WPBrowser\Exceptions\RuntimeException; +use lucatume\WPBrowser\Utils\Filesystem as FS; use Symfony\Component\Console\Output\NullOutput; use Symfony\Component\Console\Output\OutputInterface; -use lucatume\WPBrowser\Utils\Filesystem as FS; use function lucatume\WPBrowser\useMemoString; class ChromedriverInstaller { - public const ERR_INVALID_VERSION = 1; - public const ERR_INVALID_BINARY = 2; - public const ERR_UNSUPPORTED_PLATFORM = 3; - public const ERR_REMOVE_EXISTING_ZIP_FILE = 4; - public const ERR_VERSION_NOT_STRING = 5; - public const ERR_INVALID_VERSION_FORMAT = 6; - public const ERR_DESTINATION_NOT_DIR = 7; - public const ERR_FETCH_MILESTONE_DOWNLOADS = 11; - public const ERR_DECODE_MILESTONE_DOWNLOADS = 12; - public const ERR_DOWNLOAD_URL_NOT_FOUND = 13; - public const ERR_REMOVE_EXISTING_BINARY = 14; - public const ERR_MOVE_BINARY = 15; - public const ERR_DETECT_PLATFORM = 16; - public const ERR_BINARY_CHMOD = 17; + public const ERR_INVALID_BINARY = 1; + public const ERR_UNSUPPORTED_PLATFORM = 2; + public const ERR_REMOVE_EXISTING_ZIP_FILE = 3; + public const ERR_VERSION_NOT_STRING = 4; + public const ERR_INVALID_VERSION_FORMAT = 5; + public const ERR_DESTINATION_NOT_DIR = 6; + public const ERR_FETCH_MILESTONE_DOWNLOADS = 7; + public const ERR_DECODE_MILESTONE_DOWNLOADS = 8; + public const ERR_DOWNLOAD_URL_NOT_FOUND = 9; + public const ERR_REMOVE_EXISTING_BINARY = 10; + public const ERR_DETECT_PLATFORM = 11; + public const ERR_BINARY_CHMOD = 12; + private OutputInterface $output; /** @var 'linux64'|'mac-x64'|'mac-arm64'|'win32'|'win64' */ private string $platform; @@ -51,6 +50,7 @@ public function __construct( $this->output->writeln("Platform: $platform"); $binary = $binary ?? $this->detectBinary(); + $this->binary = $this->checkBinary($binary); $this->output->writeln("Binary: $binary"); @@ -61,109 +61,6 @@ public function __construct( $this->output->writeln("Version: $version"); } - /** - * @throws JsonException - */ - public function install(string $dir = null): string - { - if ($dir === null) { - global $_composer_bin_dir; - $dir = $_composer_bin_dir; - $composerEnvBinDir = getenv('COMPOSER_BIN_DIR'); - if ($composerEnvBinDir && is_string($composerEnvBinDir) && is_dir($composerEnvBinDir)) { - $dir = $composerEnvBinDir; - } - } - - if (!is_dir($dir)) { - throw new InvalidArgumentException( - "The directory $dir does not exist.", - self::ERR_DESTINATION_NOT_DIR - ); - } - - $this->output->writeln("Fetching Chromedriver version URL ..."); - - $zipFilePathname = $this->useEnvZipFile ? - Env::get('WPBROWSER_CHROMEDRIVER_ZIP_FILE', null) - : null; - $cacheDir = FS::cacheDir() . '/chromedriver'; - $executableFileName = $dir . '/' . $this->getExecutableFileName(); - - if (!(is_string($zipFilePathname) && is_file($zipFilePathname))) { - $downloadUrl = $this->fetchChromedriverVersionUrl(); - if (!is_dir($cacheDir) && !(mkdir($cacheDir, 0777, true) && is_dir($cacheDir))) { - throw new RuntimeException("Could not create Chromedriver cache directory $cacheDir."); - } - $zipFilePathname = rtrim($cacheDir, '\\/') . '/chromedriver.zip'; - if (is_file($zipFilePathname) && !unlink($zipFilePathname)) { - throw new RuntimeException( - "Could not remove existing zip file $zipFilePathname", - self::ERR_REMOVE_EXISTING_ZIP_FILE - ); - } - $this->output->writeln('Downloading Chromedriver to ' . $zipFilePathname . ' ...'); - $zipFilePathname = Download::fileFromUrl($downloadUrl, $zipFilePathname); - $this->output->writeln('Downloaded Chromedriver to ' . $zipFilePathname); - } - - if (is_file($executableFileName) && !unlink($executableFileName)) { - throw new RuntimeException( - "Could not remove existing executable file $executableFileName", - self::ERR_REMOVE_EXISTING_BINARY - ); - } - - Zip::extractFile($zipFilePathname, $this->getExecutableFileName(), $executableFileName); - - if (!chmod($executableFileName, 0755)) { - throw new RuntimeException( - "Could not make Chromedriver executable", - self::ERR_BINARY_CHMOD - ); - } - - $this->output->writeln("Installed Chromedriver to $executableFileName"); - - return $executableFileName; - } - - /** - * @throws RuntimeException - */ - private function detectVersion(): string - { - $process = match ($this->platform) { - 'linux64', 'mac-x64', 'mac-arm64' => new Process([$this->binary, ' --version']), - 'win32', 'win64' => Process::fromShellCommandline( - 'reg query "HKEY_CURRENT_USER\Software\Google\Chrome\BLBeacon" /v version' - ) - }; - - $process->run(); - $chromeVersion = $process->getOutput(); - - if ($chromeVersion === '') { - throw new RuntimeException( - "Could not detect Chrome version from $this->binary", - self::ERR_VERSION_NOT_STRING - ); - } - - $matches = []; - if (!( - preg_match('/\s*\d+\.\d+\.\d+\.\d+\s*/', $chromeVersion, $matches) - && isset($matches[0]) && is_string($matches[0]) - )) { - throw new RuntimeException( - "Could not detect Chrome version from $this->binary", - self::ERR_INVALID_VERSION_FORMAT - ); - } - - return trim($matches[0]); - } - /** * @throws RuntimeException */ @@ -216,20 +113,33 @@ private function checkPlatform(mixed $platform): string ); } - /** @var 'linux64'|'mac-x64'|'mac-arm64'|'win32'|'win64' $platform */ + /** @var 'linux64'|'mac-arm64'|'mac-x64'|'win32'|'win64' $platform */ return $platform; } - private function detectLinuxBinaryPath(): ?string + /** + * @throws RuntimeException + */ + private function detectBinary(): string + { + return match ($this->platform) { + 'linux64' => $this->detectLinuxBinaryPath(), + 'mac-x64', 'mac-arm64' => '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + 'win32', 'win64' => $this->detectWindowsBinaryPath() + }; + } + + private function detectLinuxBinaryPath(): string { foreach (['chromium', 'google-chrome'] as $bin) { $path = exec("which $bin"); if (!empty($path)) { - return $path; + return $path; } } - return null; + + return '/usr/bin/google-chrome'; } private function detectWindowsBinaryPath(): string @@ -249,29 +159,70 @@ private function detectWindowsBinaryPath(): string return $candidate; } + private function checkBinary(mixed $binary): string + { + // Replace escaped spaces with spaces to check the binary. + if (!(is_string($binary) && is_executable(str_replace('\ ', ' ', $binary)))) { + throw new RuntimeException( + "Invalid Chrome binary: not executable or not existing.\n" . + "Checked paths: " . implode(', ', $this->getBinaryCandidateList()) . "\n", + self::ERR_INVALID_BINARY + ); + } + + return $binary; + } + /** - * @throws RuntimeException + * @return string[] */ - private function detectBinary(): string + private function getBinaryCandidateList(): array { return match ($this->platform) { - 'linux64' => $this->detectLinuxBinaryPath(), - 'mac-x64', 'mac-arm64' => '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', - 'win32', 'win64' => $this->detectWindowsBinaryPath() + 'linux64' => ['chromium', 'google-chrome'], + 'mac-x64', 'mac-arm64' => ['/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'], + 'win32', 'win64' => [ + getenv('ProgramFiles') . '\\\\Google\\\\Chrome\\\\Application\\\\chrome.exe', + getenv('ProgramFiles(x86)') . '\\\\Google\\\\Chrome\\\\Application\\\\chrome.exe', + getenv('LOCALAPPDATA') . '\\\\Google\\\\Chrome\\\\Application\\\\chrome.exe' + ] }; } - private function checkBinary(mixed $binary): string + /** + * @throws RuntimeException + */ + private function detectVersion(): string { - // Replace escaped spaces with spaces to check the binary. - if (!(is_string($binary) && is_executable(str_replace('\ ', ' ', $binary)))) { + $process = match ($this->platform) { + 'linux64', 'mac-x64', 'mac-arm64' => new Process([$this->binary, ' --version']), + 'win32', 'win64' => Process::fromShellCommandline( + 'reg query "HKEY_CURRENT_USER\Software\Google\Chrome\BLBeacon" /v version' + ) + }; + + $process->run(); + $chromeVersion = $process->getOutput(); + + if ($chromeVersion === '') { throw new RuntimeException( - "Invalid Chrome binary: not executable or not existing.", - self::ERR_INVALID_BINARY + "Could not detect Chrome version from $this->binary", + self::ERR_VERSION_NOT_STRING ); } - return $binary; + $matches = []; + if (!( + preg_match('/\s*\d+\.\d+\.\d+\.\d+\s*/', $chromeVersion, $matches) + && isset($matches[0]) && is_string($matches[0]) + )) { + throw new RuntimeException( + "Could not detect Chrome version from $this->binary", + self::ERR_INVALID_VERSION_FORMAT + ); + } + + return trim($matches[0]); } private function checkVersion(mixed $version): string @@ -287,6 +238,81 @@ private function checkVersion(mixed $version): string return $matches['major']; } + /** + * @throws JsonException + */ + public function install(string $dir = null): string + { + if ($dir === null) { + global $_composer_bin_dir; + $dir = $_composer_bin_dir; + $composerEnvBinDir = getenv('COMPOSER_BIN_DIR'); + if ($composerEnvBinDir && is_string($composerEnvBinDir) && is_dir($composerEnvBinDir)) { + $dir = $composerEnvBinDir; + } + } + + if (!is_dir($dir)) { + throw new InvalidArgumentException( + "The directory $dir does not exist.", + self::ERR_DESTINATION_NOT_DIR + ); + } + + $this->output->writeln("Fetching Chromedriver version URL ..."); + + $zipFilePathname = $this->useEnvZipFile ? + Env::get('WPBROWSER_CHROMEDRIVER_ZIP_FILE', null) + : null; + $cacheDir = FS::cacheDir() . '/chromedriver'; + $executableFileName = $dir . '/' . $this->getExecutableFileName(); + + if (!(is_string($zipFilePathname) && is_file($zipFilePathname))) { + $downloadUrl = $this->fetchChromedriverVersionUrl(); + if (!is_dir($cacheDir) && !(mkdir($cacheDir, 0777, true) && is_dir($cacheDir))) { + throw new RuntimeException("Could not create Chromedriver cache directory $cacheDir."); + } + $zipFilePathname = rtrim($cacheDir, '\\/') . '/chromedriver.zip'; + if (is_file($zipFilePathname) && !unlink($zipFilePathname)) { + throw new RuntimeException( + "Could not remove existing zip file $zipFilePathname", + self::ERR_REMOVE_EXISTING_ZIP_FILE + ); + } + $this->output->writeln('Downloading Chromedriver to ' . $zipFilePathname . ' ...'); + $zipFilePathname = Download::fileFromUrl($downloadUrl, $zipFilePathname); + $this->output->writeln('Downloaded Chromedriver to ' . $zipFilePathname); + } + + if (is_file($executableFileName) && !unlink($executableFileName)) { + throw new RuntimeException( + "Could not remove existing executable file $executableFileName", + self::ERR_REMOVE_EXISTING_BINARY + ); + } + + Zip::extractFile($zipFilePathname, $this->getExecutableFileName(), $executableFileName); + + if (!chmod($executableFileName, 0755)) { + throw new RuntimeException( + "Could not make Chromedriver executable", + self::ERR_BINARY_CHMOD + ); + } + + $this->output->writeln("Installed Chromedriver to $executableFileName"); + + return $executableFileName; + } + + private function getExecutableFileName(): string + { + return match ($this->platform) { + 'linux64', 'mac-x64', 'mac-arm64' => 'chromedriver', + 'win32', 'win64' => 'chromedriver.exe' + }; + } + private function fetchChromedriverVersionUrl(): string { return useMemoString( @@ -325,7 +351,9 @@ private function unmemoizedFetchChromedriverVersionUrl(): string && is_array($decoded['milestones'][$this->milestone]['downloads']['chromedriver']) )) { throw new RuntimeException( - 'Failed to decode known good Chrome and Chromedriver versions with downloads. Try upgrading chrome. ' . $this->detectLinuxBinaryPath(), + "Failed to find a version of Chromedriver to download for your platform and Chrome combination." . + "\nTry upgrading Chrome and making sure it is executable from one of the expected locations for your " . + "platform ({$this->platform}): " . implode(', ', $this->getBinaryCandidateList()), self::ERR_DECODE_MILESTONE_DOWNLOADS ); } @@ -350,14 +378,6 @@ private function unmemoizedFetchChromedriverVersionUrl(): string ); } - private function getExecutableFileName(): string - { - return match ($this->platform) { - 'linux64', 'mac-x64', 'mac-arm64' => 'chromedriver', - 'win32', 'win64' => 'chromedriver.exe' - }; - } - public function getVersion(): string { return $this->milestone; diff --git a/tests/unit/lucatume/WPBrowser/Utils/ChromedriverInstallerTest.php b/tests/unit/lucatume/WPBrowser/Utils/ChromedriverInstallerTest.php index ddb567b8b..e3e687608 100644 --- a/tests/unit/lucatume/WPBrowser/Utils/ChromedriverInstallerTest.php +++ b/tests/unit/lucatume/WPBrowser/Utils/ChromedriverInstallerTest.php @@ -42,11 +42,58 @@ public function should_throw_if_specified_platform_is_not_supported(): void } /** - * It should throw if binary cannot be found + * It should throw if specified binary cannot be found * * @test */ - public function should_throw_if_binary_cannot_be_found(): void + public function should_throw_if_specified_binary_cannot_be_found(): void + { + $this->uopzSetFunctionReturn('is_file', fn(string $file) => !str_contains($file, 'chrome'), true); + $this->expectException(RuntimeException::class); + $this->expectExceptionCode(ChromedriverInstaller::ERR_INVALID_BINARY); + + new ChromedriverInstaller(null, 'win32', '/path/to/chrome.exe'); + } + + /** + * @return string[] + */ + public function platforms_provider(): array + { + return [ + 'win32' => ['win32', 'chrome'], + 'win64' => ['win64', 'chrome'], + 'linux64' => ['linux64', 'chrom'], + 'mac-x64' => ['mac-x64', 'Chrome'], + 'mac-arm64' => ['mac-arm64', 'Chrome'], + ]; + } + + /** + * It should throw if binary cannot be found in default paths for platform + * + * @test + * @dataProvider platforms_provider + */ + public function should_throw_if_binary_cannot_be_found_in_default_paths_for_platform( + string $platform, + string $binNamePattern + ): void { + $isNotAnExecutableFile = fn(string $file) => !str_contains($file, $binNamePattern); + $this->uopzSetFunctionReturn('is_file', $isNotAnExecutableFile, true); + $this->uopzSetFunctionReturn('is_executable', $isNotAnExecutableFile, true); + $this->expectException(RuntimeException::class); + $this->expectExceptionCode(ChromedriverInstaller::ERR_INVALID_BINARY); + + new ChromedriverInstaller(null, $platform); + } + + /** + * It should throw if binary cannot be executed + * + * @test + */ + public function should_throw_if_binary_cannot_be_executed(): void { $this->uopzSetFunctionReturn('is_executable', function (string $file): bool { return !str_contains($file, 'chrome') && is_executable($file); @@ -72,9 +119,11 @@ public function should_throw_if_specified_binary_is_not_valid(): void $this->expectException(RuntimeException::class); $this->expectExceptionCode(ChromedriverInstaller::ERR_INVALID_BINARY); - new ChromedriverInstaller('1.2.3.4', + new ChromedriverInstaller( + '1.2.3.4', 'mac-arm64', - '/Applications/Chromium.app/Contents/MacOS/Chromium'); + '/Applications/Chromium.app/Contents/MacOS/Chromium' + ); } /** @@ -87,9 +136,11 @@ public function should_throw_if_version_from_binary_is_not_a_string(): void $this->expectException(RuntimeException::class); $this->expectExceptionCode(ChromedriverInstaller::ERR_VERSION_NOT_STRING); - new ChromedriverInstaller(null, + new ChromedriverInstaller( + null, null, - codecept_data_dir('bins/chrome-version-not-string')); + codecept_data_dir('bins/chrome-version-not-string') + ); } /** @@ -99,7 +150,7 @@ public function should_throw_if_version_from_binary_is_not_a_string(): void */ public function should_throw_if_version_from_binary_has_not_correct_format(): void { - $this->uopzSetFunctionReturn('exec','Could not start Google Chrome.'); + $this->uopzSetFunctionReturn('exec', 'Could not start Google Chrome.'); $this->expectException(RuntimeException::class); $this->expectExceptionCode(ChromedriverInstaller::ERR_INVALID_VERSION_FORMAT); @@ -184,6 +235,9 @@ public function should_throw_if_response_is_not_valid_json(): void $this->expectException(RuntimeException::class); $this->expectExceptionCode(ChromedriverInstaller::ERR_DECODE_MILESTONE_DOWNLOADS); + $this->expectExceptionMessage("Failed to find a version of Chromedriver to download for your platform and " . + "Chrome combination.\nTry upgrading Chrome and making sure it is executable from one of the expected " . + "locations for your platform (linux64): chromium, google-chrome"); $ci->install(__DIR__); } @@ -197,8 +251,8 @@ public function should_throw_if_download_url_for_chrome_version_cannot_be_found_ { $this->uopzSetFunctionReturn('file_get_contents', function (string $file): string|false { return str_contains($file, 'chrome-for-testing') ? - '{"milestones":{"116": {"downloads":{"chrome":{},"chromedriver":{}}}}}' - : file_get_contents($file); + '{"milestones":{"116": {"downloads":{"chrome":{},"chromedriver":{}}}}}' + : file_get_contents($file); }, true); $ci = new ChromedriverInstaller(null, 'linux64', codecept_data_dir('bins/chrome-mock')); @@ -219,7 +273,7 @@ public function should_throw_if_existing_zip_file_cannot_be_removed(): void { $this->uopzSetFunctionReturn('sys_get_temp_dir', codecept_output_dir()); $this->uopzSetFunctionReturn('unlink', function (string $file): bool { - return preg_match('~chromedriver\\.zip$~' ,$file) ? false : unlink($file); + return preg_match('~chromedriver\\.zip$~', $file) ? false : unlink($file); }, true); $ci = new ChromedriverInstaller(null, 'linux64', codecept_data_dir('bins/chrome-mock'));