diff --git a/composer.json b/composer.json index 3beffc2fd..7fd2696b2 100644 --- a/composer.json +++ b/composer.json @@ -13,10 +13,11 @@ "php": "^8.0", "ext-json": "*", "ext-zip": "*", - "php-webdriver/webdriver": "^1.9.0", - "nesbot/carbon": "^2.0", + "guzzlehttp/guzzle": "^7.2", "illuminate/console": "^9.0|^10.0", "illuminate/support": "^9.0|^10.0", + "nesbot/carbon": "^2.0", + "php-webdriver/webdriver": "^1.9.0", "symfony/console": "^6.0", "symfony/finder": "^6.0", "symfony/process": "^6.0", diff --git a/src/Console/ChromeDriverCommand.php b/src/Console/ChromeDriverCommand.php index ea909ca79..269cb7e9d 100644 --- a/src/Console/ChromeDriverCommand.php +++ b/src/Console/ChromeDriverCommand.php @@ -2,7 +2,11 @@ namespace Laravel\Dusk\Console; +use Exception; +use GuzzleHttp\Psr7\Utils; use Illuminate\Console\Command; +use Illuminate\Support\Facades\Http; +use Illuminate\Support\Str; use Laravel\Dusk\OperatingSystem; use Symfony\Component\Process\Process; use ZipArchive; @@ -31,41 +35,7 @@ class ChromeDriverCommand extends Command protected $description = 'Install the ChromeDriver binary'; /** - * URL to the latest stable release version. - * - * @var string - */ - protected $latestVersionUrl = 'https://chromedriver.storage.googleapis.com/LATEST_RELEASE'; - - /** - * URL to the latest release version for a major Chrome version. - * - * @var string - */ - protected $versionUrl = 'https://chromedriver.storage.googleapis.com/LATEST_RELEASE_%d'; - - /** - * URL to the ChromeDriver download. - * - * @var string - */ - protected $downloadUrl = 'https://chromedriver.storage.googleapis.com/%s/chromedriver_%s.zip'; - - /** - * Download slugs for the available operating systems. - * - * @var array - */ - protected $slugs = [ - 'linux' => 'linux64', - 'mac' => 'mac64', - 'mac-intel' => 'mac64', - 'mac-arm' => 'mac_arm64', - 'win' => 'win32', - ]; - - /** - * The legacy versions for the ChromeDriver. + * The legacy versions for ChromeDriver. * * @var array */ @@ -106,29 +76,6 @@ class ChromeDriverCommand extends Command */ protected $directory = __DIR__.'/../../bin/'; - /** - * The default commands to detect the installed Chrome / Chromium version. - * - * @var array - */ - protected $chromeVersionCommands = [ - 'linux' => [ - '/usr/bin/google-chrome --version', - '/usr/bin/chromium-browser --version', - '/usr/bin/chromium --version', - '/usr/bin/google-chrome-stable --version', - ], - 'mac-intel' => [ - '/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --version', - ], - 'mac-arm' => [ - '/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --version', - ], - 'win' => [ - 'reg query "HKEY_CURRENT_USER\Software\Google\Chrome\BLBeacon" /v version', - ], - ]; - /** * Execute the console command. * @@ -142,11 +89,11 @@ public function handle() $currentOS = OperatingSystem::id(); - foreach ($this->slugs as $os => $slug) { + foreach (OperatingSystem::all() as $os) { if ($all || ($os === $currentOS)) { - $archive = $this->download($version, $slug); + $archive = $this->download($version, $os); - $binary = $this->extract($archive); + $binary = $this->extract($version, $archive); $this->rename($binary, $os); } @@ -182,11 +129,14 @@ protected function version() if ($version < 70) { return $this->legacyVersions[$version]; + } elseif ($version < 115) { + return $this->fetchChromeVersionFromUrl($version); } - return trim($this->getUrl( - sprintf($this->versionUrl, $version) - )); + $milestones = $this->resolveChromeVersionsPerMilestone(); + + return $milestones['milestones'][$version]['version'] + ?? throw new Exception('Could not determine the ChromeDriver version.'); } /** @@ -196,22 +146,10 @@ protected function version() */ protected function latestVersion() { - $streamOptions = []; - - if ($this->option('ssl-no-verify')) { - $streamOptions = [ - 'ssl' => [ - 'verify_peer' => false, - 'verify_peer_name' => false, - ], - ]; - } - - if ($this->option('proxy')) { - $streamOptions['http'] = ['proxy' => $this->option('proxy'), 'request_fulluri' => true]; - } + $versions = json_decode($this->getUrl('https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions-with-downloads.json'), true); - return trim(file_get_contents($this->latestVersionUrl, false, stream_context_create($streamOptions))); + return $versions['channels']['Stable']['version'] + ?? throw new Exception('Could not get the latest ChromeDriver version.'); } /** @@ -222,7 +160,7 @@ protected function latestVersion() */ protected function detectChromeVersion($os) { - foreach ($this->chromeVersionCommands[$os] as $command) { + foreach (OperatingSystem::chromeVersionCommands($os) as $command) { $process = Process::fromShellCommandline($command); $process->run(); @@ -245,17 +183,21 @@ protected function detectChromeVersion($os) * Download the ChromeDriver archive. * * @param string $version - * @param string $slug + * @param string $os * @return string */ - protected function download($version, $slug) + protected function download($version, $os) { - $url = sprintf($this->downloadUrl, $version, $slug); + $url = $this->resolveChromeDriverDownloadUrl($version, $os); - file_put_contents( - $archive = $this->directory.'chromedriver.zip', - $this->getUrl($url) - ); + $resource = Utils::tryFopen($archive = $this->directory.'chromedriver.zip', 'w'); + + Http::withOptions(array_merge([ + 'verify' => $this->option('ssl-no-verify') === false, + 'sink' => $resource, + ]), array_filter([ + 'proxy' => $this->option('proxy'), + ]))->get($url); return $archive; } @@ -263,10 +205,11 @@ protected function download($version, $slug) /** * Extract the ChromeDriver binary from the archive and delete the archive. * + * @param string $version * @param string $archive * @return string */ - protected function extract($archive) + protected function extract($version, $archive) { $zip = new ZipArchive; @@ -274,7 +217,7 @@ protected function extract($archive) $zip->extractTo($this->directory); - $binary = $zip->getNameIndex(0); + $binary = $zip->getNameIndex(version_compare($version, '115.0', '<') ? 0 : 1); $zip->close(); @@ -292,7 +235,9 @@ protected function extract($archive) */ protected function rename($binary, $os) { - $newName = str_replace('chromedriver', 'chromedriver-'.$os, $binary); + $newName = Str::contains($binary, DIRECTORY_SEPARATOR) + ? Str::after(str_replace('chromedriver', 'chromedriver-'.$os, $binary), DIRECTORY_SEPARATOR) + : str_replace('chromedriver', 'chromedriver-'.$os, $binary); rename($this->directory.$binary, $this->directory.$newName); @@ -300,25 +245,67 @@ protected function rename($binary, $os) } /** - * Get the contents of a URL using the 'proxy' and 'ssl-no-verify' command options. + * Get the Chrome version from URL. * - * @param string $url - * @return string|bool + * @return string */ - protected function getUrl(string $url) + protected function fetchChromeVersionFromUrl(int $version) { - $contextOptions = []; + return trim((string) $this->getUrl( + sprintf('https://chromedriver.storage.googleapis.com/LATEST_RELEASE_%d', $version) + )); + } - if ($this->option('proxy')) { - $contextOptions['http'] = ['proxy' => $this->option('proxy'), 'request_fulluri' => true]; - } + /** + * Get the Chrome versions per milestone. + * + * @return array + */ + protected function resolveChromeVersionsPerMilestone() + { + return json_decode( + $this->getUrl('https://googlechromelabs.github.io/chrome-for-testing/latest-versions-per-milestone-with-downloads.json'), true + ); + } - if ($this->option('ssl-no-verify')) { - $contextOptions['ssl'] = ['verify_peer' => false]; + /** + * Resolve the download URL. + * + * @return string + * + * @throws \Exception + */ + protected function resolveChromeDriverDownloadUrl(string $version, string $os) + { + $slug = OperatingSystem::chromeDriverSlug($os, $version); + + if (version_compare($version, '115.0', '<')) { + return sprintf('https://chromedriver.storage.googleapis.com/%s/chromedriver_%s.zip', $version, $slug); } - $streamContext = stream_context_create($contextOptions); + $milestone = (int) $version; + + $versions = $this->resolveChromeVersionsPerMilestone(); + + /** @var array $chromedrivers */ + $chromedrivers = $versions['milestones'][$milestone]['downloads']['chromedriver'] + ?? throw new Exception('Could not get the ChromeDriver version.'); + + return collect($chromedrivers)->firstWhere('platform', $slug)['url'] + ?? throw new Exception('Could not get the ChromeDriver version.'); + } - return file_get_contents($url, false, $streamContext); + /** + * Get the contents of a URL using the 'proxy' and 'ssl-no-verify' command options. + * + * @return string + */ + protected function getUrl(string $url) + { + return Http::withOptions(array_merge([ + 'verify' => $this->option('ssl-no-verify') === false, + ]), array_filter([ + 'proxy' => $this->option('proxy'), + ]))->get($url)->body(); } } diff --git a/src/OperatingSystem.php b/src/OperatingSystem.php index bd6021a85..d8ea37746 100644 --- a/src/OperatingSystem.php +++ b/src/OperatingSystem.php @@ -3,11 +3,109 @@ namespace Laravel\Dusk; use Illuminate\Support\Str; +use InvalidArgumentException; class OperatingSystem { /** - * Returns the current OS identifier. + * List of available operating system platforms. + * + * @var array}> + */ + protected static $platforms = [ + 'linux' => [ + 'slug' => 'linux64', + 'commands' => [ + '/usr/bin/google-chrome --version', + '/usr/bin/chromium-browser --version', + '/usr/bin/chromium --version', + '/usr/bin/google-chrome-stable --version', + ], + ], + 'mac' => [ + 'slug' => 'mac-x64', + 'commands' => [ + '/Applications/Google\ Chrome\ for\ Testing.app/Contents/MacOS/Google\ Chrome\ for\ Testing --version', + '/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --version', + ], + ], + 'mac-intel' => [ + 'slug' => 'mac-x64', + 'commands' => [ + '/Applications/Google\ Chrome\ for\ Testing.app/Contents/MacOS/Google\ Chrome\ for\ Testing --version', + '/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --version', + ], + ], + 'mac-arm' => [ + 'slug' => 'mac-arm64', + 'commands' => [ + '/Applications/Google\ Chrome\ for\ Testing.app/Contents/MacOS/Google\ Chrome\ for\ Testing --version', + '/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --version', + ], + ], + 'win' => [ + 'slug' => 'win32', + 'commands' => [ + 'reg query "HKEY_CURRENT_USER\Software\Google\Chrome\BLBeacon" /v version', + ], + ], + ]; + + /** + * Resolve the Chrome version commands for the given operating system. + * + * @param string $operatingSystem + * @return array + */ + public static function chromeVersionCommands($operatingSystem) + { + $commands = static::$platforms[$operatingSystem]['commands'] ?? null; + + if (is_null($commands)) { + throw new InvalidArgumentException("Unable to find commands for Operating System [{$operatingSystem}]"); + } + + return $commands; + } + + /** + * Resolve the ChromeDriver slug for the given operating system. + * + * @param string $operatingSystem + * @param string|null $version + * @return string + */ + public static function chromeDriverSlug($operatingSystem, $version = null) + { + $slug = static::$platforms[$operatingSystem]['slug'] ?? null; + + if (is_null($slug)) { + throw new InvalidArgumentException("Unable to find ChromeDriver slug for Operating System [{$operatingSystem}]"); + } + + if (! is_null($version) && version_compare($version, '115.0', '<')) { + if ($slug === 'mac-arm64') { + return version_compare($version, '106.0.5249', '<') ? 'mac64_m1' : 'mac_arm64'; + } elseif ($slug === 'mac-x64') { + return 'mac64'; + } + } + + return $slug; + } + + /** + * Get all supported operating systems. + * + * @return array + */ + public static function all() + { + return array_keys(static::$platforms); + } + + /** + * Get the current operating system identifier. * * @return string */ @@ -43,7 +141,7 @@ public static function onMac() } /** - * Mac platform architecture. + * Get the current macOS platform architecture. * * @return string */ diff --git a/tests/OperatingSystemTest.php b/tests/OperatingSystemTest.php new file mode 100644 index 000000000..7d8aa4fd8 --- /dev/null +++ b/tests/OperatingSystemTest.php @@ -0,0 +1,82 @@ +assertTrue(\in_array(OperatingSystem::id(), OperatingSystem::all())); + } + + public function test_it_has_correct_os() + { + $this->assertSame([ + 'linux', + 'mac', + 'mac-intel', + 'mac-arm', + 'win', + ], OperatingSystem::all()); + } + + public function test_it_can_resolve_chrome_version_commands() + { + foreach (OperatingSystem::all() as $os) { + $commands = OperatingSystem::chromeVersionCommands($os); + + $this->assertTrue(is_array($commands), 'Commands should be an array'); + $this->assertFalse(empty($commands), 'Commands should not be empty'); + } + } + + public function test_it_cant_resolve_invalid_chrome_version_commands() + { + $this->expectException('InvalidArgumentException'); + $this->expectExceptionMessage('Unable to find commands for Operating System [window_os]'); + + OperatingSystem::chromeVersionCommands('window_os'); + } + + /** + * @dataProvider resolveChromeDriverSlugDataProvider + */ + public function test_it_can_resolve_chromedriver_slug($version, $os, $expected) + { + $this->assertSame($expected, OperatingSystem::chromeDriverSlug($os, $version)); + } + + public function test_it_cant_resolve_invalid_chromedriver_slug() + { + $this->expectException('InvalidArgumentException'); + $this->expectExceptionMessage('Unable to find ChromeDriver slug for Operating System [window_os]'); + + OperatingSystem::chromeDriverSlug('window_os'); + } + + public static function resolveChromeDriverSlugDataProvider() + { + yield ['115.0', 'linux', 'linux64']; + yield ['113.0', 'linux', 'linux64']; + yield ['105.0', 'linux', 'linux64']; + + yield ['115.0', 'mac', 'mac-x64']; + yield ['113.0', 'mac', 'mac64']; + yield ['105.0', 'mac', 'mac64']; + + yield ['115.0', 'mac-intel', 'mac-x64']; + yield ['113.0', 'mac-intel', 'mac64']; + yield ['105.0', 'mac-intel', 'mac64']; + + yield ['115.0', 'mac-arm', 'mac-arm64']; + yield ['113.0', 'mac-arm', 'mac_arm64']; + yield ['105.0', 'mac-arm', 'mac64_m1']; + + yield ['115.0', 'win', 'win32']; + yield ['113.0', 'win', 'win32']; + yield ['105.0', 'win', 'win32']; + } +}