From 1fee1451b23edf6167546a4825f0b3d06604f014 Mon Sep 17 00:00:00 2001 From: "theAverageDev (Luca Tumedei)" Date: Fri, 29 Sep 2023 13:45:24 +0200 Subject: [PATCH] fix 641 (#644) * build(composer.json) remove unused dev req. * fix(ChromedriverInstaller) correctly detect Chrome version on Windows * fix(ChromedriverInstaller) Windows support * doc(docs) add troubleshooting guide first entry * refactor(src) use Syphony process in Loop * fix(ChromedriverInstaller) command on Windows * fix(ChromedriverInstaller) version detection --- bin/setup-wp.php | 3 + composer.json | 5 +- docs/commands.md | 2 + docs/troubleshooting.md | 18 +++ mkdocs.yml | 1 + src/Process/Loop.php | 110 --------------- src/Process/Worker/Running.php | 201 +++++---------------------- src/Process/Worker/WorkerProcess.php | 61 ++++++++ src/Utils/ChromedriverInstaller.php | 96 +++++++++---- 9 files changed, 186 insertions(+), 311 deletions(-) create mode 100644 docs/troubleshooting.md create mode 100644 src/Process/Worker/WorkerProcess.php diff --git a/bin/setup-wp.php b/bin/setup-wp.php index 1b0993510..1404b11f0 100644 --- a/bin/setup-wp.php +++ b/bin/setup-wp.php @@ -10,6 +10,9 @@ use lucatume\WPBrowser\WordPress\InstallationState\InstallationStateInterface; use lucatume\WPBrowser\WordPress\InstallationState\Scaffolded; +$dockerComposeEnvFile = escapeshellarg(dirname(__DIR__) . '/tests/.env'); +`docker compose --env-file $dockerComposeEnvFile up --wait`; + require_once dirname(__DIR__) . '/vendor/autoload.php'; global $_composer_autoload_path, $_composer_bin_dir; diff --git a/composer.json b/composer.json index 2a4029410..eb409fc6e 100644 --- a/composer.json +++ b/composer.json @@ -39,16 +39,13 @@ "ifsnop/mysqldump-php": "^2.12" }, "require-dev": { - "ext-sockets": "*", - "ext-pcntl": "*", "ext-mysqli": "*", "lucatume/codeception-snapshot-assertions": "^1.0.0", "gumlet/php-image-resize": "^1.6", "szepeviktor/phpstan-wordpress": "^1.3", "phpstan/extension-installer": "^1.3", "phpstan/phpstan-symfony": "^1.3", - "squizlabs/php_codesniffer": "^3.7", - "webdriver-binary/binary-chromedriver": "^6.1" + "squizlabs/php_codesniffer": "^3.7" }, "autoload": { "psr-4": { diff --git a/docs/commands.md b/docs/commands.md index c9e6f1857..24a39a728 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -192,6 +192,8 @@ use this command to update it. This command will download the latest version of Chromedriver compatible with the Chrome version installed on your machine in the Composer vendor directory. +> Note: if the download fails, it might be [a certificate issue](troubleshooting.md#downloads-fail-in-windows). + ### `generate:wpunit` Enable the command with: diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 000000000..61bb1c3c6 --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,18 @@ +## Troubleshooting common issues + +### Downloads fail in Windows + +If you're using code or commands, e.g. [the `chromedriver:update` one](commands.md#chromedriverupdate), that download files and those keep failing with a message like the following: + +``` +File ... download failed: SSL certificate problem: unable to get local issuer certificate +``` + +It's likely the issue originates from PHP not having access to the system certificate store. + +You can fix this by downloading the [certificates](https://curl.haxx.se/docs/caextract.html) file and setting the `curl.cainfo` and `openssl.cafile` PHP configuration options to point to it: + +```ini +curl.cainfo = "C:\path\to\cacert.pem" +openssl.cafile = "C:\path\to\cacert.pem" +``` diff --git a/mkdocs.yml b/mkdocs.yml index 247ebfd80..499d64849 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -33,6 +33,7 @@ nav: - wp:db:export: 'commands#wpdbexport' - chromedriver:update: 'commands#chromedriverupdate' - generate:wpunit: 'commands#generatewpunit' + - Troubleshooting: 'troubleshooting.md' - Sponsorship: 'https://github.com/sponsors/lucatume' - Changelog: 'https://github.com/lucatume/wp-browser/blob/master/CHANGELOG.md' - Version 3 documentation: diff --git a/src/Process/Loop.php b/src/Process/Loop.php index 32e45211f..75b9f16b9 100644 --- a/src/Process/Loop.php +++ b/src/Process/Loop.php @@ -38,7 +38,6 @@ class Loop * @var array */ private array $workers = []; - private bool $debugMode = false; private bool $fastFailureFlagRaised = false; @@ -107,14 +106,6 @@ public static function executeClosureOrFail( return self::executeClosure($toInstallWordPressNetwork, $timeout, $options); } - /** - * @return array - */ - public function getWorkers(): array - { - return $this->workers; - } - /** * @param array $workers * @param array{ @@ -193,7 +184,6 @@ private function startWorker(): void $w = Running::fromWorker($runnableWorker); $this->started[$w->getId()] = $w; $this->running[$w->getId()] = $w; - $this->debugLine("Worker {$w->getId()} started."); $this->peakParallelism = max((int)$this->peakParallelism, count($this->running)); } catch (Throwable $t) { $this->terminateAllRunningWorkers(); @@ -229,7 +219,6 @@ private function collectOutput(): void $read = array_reduce( $this->running, function (array $streams, Running $w) use (&$readIndexToWorkerMap): array { - $this->debugLine("Collecting output of worker {$w->getId()}."); $streams[] = $w->getStdoutStream(); $streams[] = $w->getStdErrStream(); $readIndexToWorkerMap[count($streams) - 2] = $w; @@ -305,7 +294,6 @@ public function run(): Loop if ($fastFailureFlagRaised) { $this->fastFailureFlagRaised = true; - $this->debugLine('Fast failure flag raised, terminating all workers.'); $this->terminateAllRunningWorkers(); break 2; } @@ -314,7 +302,6 @@ public function run(): Loop $exitedWorker = Exited::fromRunningWorker($w); $this->exited[$w->getId()] = $exitedWorker; unset($this->running[$w->getId()]); - $this->debugLine("Worker {$w->getId()} exited with status {$w->getExitCode()}."); $this->startWorker(); continue; } @@ -323,7 +310,6 @@ public function run(): Loop $exitedWorker = $w->terminate(); $this->exited[$w->getId()] = $exitedWorker; unset($this->running[$w->getId()]); - $this->debugLine("Worker {$w->getId()} took too long, terminated."); $this->startWorker(); } } @@ -334,10 +320,6 @@ public function run(): Loop $this->collectOutput(); $this->collectResults(); - if ($this->debugMode) { - $this->assertPostRunConditions(); - } - return $this; } @@ -353,7 +335,6 @@ private function terminateAllRunningWorkers(): Loop { foreach ($this->running as $runningWorker) { $this->exited[$runningWorker->getId()] = $runningWorker->terminate(); - $this->debugLine("Worker {$runningWorker->getId()} terminated."); } $this->running = array_diff_key($this->running, $this->exited); @@ -361,88 +342,6 @@ private function terminateAllRunningWorkers(): Loop return $this; } - protected function assertPostRunConditions(): void - { - assert(count($this->started) >= 1); - assert(count($this->started) === count($this->exited)); - $uncollectedWorkerOutput = array_map(static function (Running $w): array { - return ['stdout' => $w->readStdoutStream(), 'stderr' => $w->readStderrStream()]; - }, array_values($this->started)); - assert(array_sum(array_merge(...$uncollectedWorkerOutput)) === 0, print_r($uncollectedWorkerOutput, true)); - if (!$this->fastFailure) { - assert(count($this->started) === count($this->workers)); - } - } - - public function getPeakParallelism(): int - { - return $this->peakParallelism; - } - - private function getExitedWorkerById(string $workerId): ?Exited - { - foreach ($this->exited as $id => $worker) { - if ($workerId === $id) { - return $worker; - } - } - - return null; - } - - private function getRunningWorkerById(string $workerId): ?Running - { - foreach ($this->running as $id => $worker) { - if ($workerId === $id) { - return $worker; - } - } - - return null; - } - - private function getWorkerById(string $workerId): ?WorkerInterface - { - if ($worker = $this->getExitedWorkerById($workerId)) { - return $worker; - } - - if ($worker = $this->getRunningWorkerById($workerId)) { - return $worker; - } - - foreach ($this->workers as $id => $worker) { - if ($workerId === $id) { - return $worker; - } - } - - return null; - } - - public function removeWorker(string $workerId): ?WorkerInterface - { - if ($worker = $this->getWorkerById($workerId)) { - unset( - $this->started[$workerId], - $this->running[$workerId], - $this->exited[$workerId], - $this->results[$workerId], - $this->workers[$workerId] - ); - - if ($worker instanceof Running) { - return $worker->terminate(); - } - - $this->sortWorkersByResource(); - - return $worker; - } - - return null; - } - public function failed(): bool { return $this->fastFailure && $this->fastFailureFlagRaised; @@ -467,13 +366,4 @@ private function buildWorker(string $id, callable $worker): Worker { return new Worker($id, $worker, [], []); } - - private function debugLine(string $line): void - { - if (!$this->debugMode) { - return; - } - - codecept_debug("Loop: $line"); - } } diff --git a/src/Process/Worker/Running.php b/src/Process/Worker/Running.php index bbc2d051e..3f892c21b 100644 --- a/src/Process/Worker/Running.php +++ b/src/Process/Worker/Running.php @@ -2,13 +2,14 @@ namespace lucatume\WPBrowser\Process\Worker; -use BadMethodCallException; use Closure; +use Codeception\Exception\ConfigurationException; +use lucatume\WPBrowser\Exceptions\RuntimeException; +use lucatume\WPBrowser\Opis\Closure\SerializableClosure; use lucatume\WPBrowser\Process\MemoryUsage; +use lucatume\WPBrowser\Process\ProcessException; use lucatume\WPBrowser\Process\Protocol\Request; use lucatume\WPBrowser\Process\Protocol\Response; -use lucatume\WPBrowser\Opis\Closure\SerializableClosure; -use RuntimeException; class Running implements WorkerInterface { @@ -18,72 +19,23 @@ class Running implements WorkerInterface * @var mixed|null */ private mixed $returnValue = null; - /** - * @var resource - */ - private $stdin; - /** - * @var array{ - * command: string, - * pid: int, - * running: bool, - * signaled: bool, - * stopped: bool, - * exitcode: int, - * termsig: int, - * stopsig: int - * }|null - */ - private ?array $cachedStatus = null; - /** - * @var resource - */ - private $proc; - /** - * @var resource - */ - private $stdout; - /** - * @var resource - */ - private $stderr; - private string $stdoutBuffer = ''; private string $stderrBuffer = ''; private bool $didExtractReturnValueFromStderr = false; /** - * @param resource $proc - * @param array $pipes * @param string[] $requiredResourcesIds */ public function __construct( private string $id, - $proc, - array $pipes, - private float $startTime, + private WorkerProcess $proc, private array $requiredResourcesIds = [] ) { - [$this->stdin, $this->stdout, $this->stderr] = $pipes; - - if (!is_resource($proc)) { - throw new BadMethodCallException('proc must be a resource'); - } - - if (!is_resource($this->stdin)) { - throw new BadMethodCallException('stdin must be a resource'); - } - - if (!is_resource($this->stdout)) { - throw new BadMethodCallException('stdout must be a resource'); - } - - if (!is_resource($this->stderr)) { - throw new BadMethodCallException('stderr must be a resource'); - } - $this->proc = $proc; } + /** + * @throws ConfigurationException|ProcessException + */ public static function fromWorker(Worker $worker): Running { $workerCallable = $worker->getCallable(); @@ -100,69 +52,24 @@ public static function fromWorker(Worker $worker): Running $request = new Request($control, $workerSerializableClosure); - $workerCommand = sprintf( - "%s %s %s", - escapeshellarg(PHP_BINARY), - escapeshellarg($workerScriptPathname), - escapeshellarg($request->getPayload()) - ); - $pipesDef = [ - 0 => ['pipe', 'r'], - 1 => ['pipe', 'w'], - 2 => ['pipe', 'w'] - ]; - $startTime = microtime(true); - $workerProc = proc_open($workerCommand, $pipesDef, $pipes); - - if (!is_resource($workerProc)) { - throw new RuntimeException('Failed to open process.'); + try { + $workerProcess = new WorkerProcess([PHP_BINARY, $workerScriptPathname, $request->getPayload()]); + $workerProcess->start(); + } catch (\Exception $e) { + throw new ProcessException( + "Failed to start the worker process: {$e->getMessage()}", + $e->getCode(), + $e + ); } return new Running( $worker->getId(), - $workerProc, - $pipes, - $startTime, + $workerProcess, $worker->getRequiredResourcesIds() ); } - /** - * @return array{ - * command: string, - * pid: int, - * running: bool, - * signaled: bool, - * stopped: bool, - * exitcode: int, - * termsig: int, - * stopsig: int - * } - */ - public function getStatus(): array - { - $liveStatus = is_resource($this->proc) ? - proc_get_status($this->proc) : - [ - 'command' => '', - 'pid' => -1, - 'running' => false, - 'signaled' => false, - 'stopped' => false, - 'exitcode' => -1, - 'termsig' => -1, - 'stopsig' => -1, - ]; - - if ($this->cachedStatus !== null && $liveStatus['exitcode'] === -1) { - return $this->cachedStatus; - } - - $this->cachedStatus = $liveStatus; - - return $liveStatus; - } - private function kill(int $pid): void { DIRECTORY_SEPARATOR === '\\' ? @@ -172,48 +79,20 @@ private function kill(int $pid): void public function terminate(): Exited { - $status = $this->getStatus(); + $pid = $this->proc->getPid(); - if (isset($status['pid'])) { - $pid = (int)$status['pid']; + if (!empty($pid)) { $this->kill($pid); } - foreach ([ - 'STDIN' => $this->stdin, - 'STDOUT' => $this->stdout, - 'STDERR' => $this->stderr, - ] as $name => $resource - ) { - if (is_resource($resource) && !fclose($resource)) { - throw new RuntimeException("Failed to close the $name pipe."); - } - } - - // Kill signal. - $status = $this->getStatus(); - $procClose = proc_close($this->proc); - if ($procClose >= 0) { - // Update the cached status if the process had not already terminated. - $this->cachedStatus = [ - 'command' => $status['command'], - 'pid' => $status['pid'], - 'running' => false, - 'signaled' => true, - 'stopped' => false, - 'exitcode' => $procClose, - 'termsig' => $status['termsig'], - 'stopsig' => $status['stopsig'], - ]; - } + $this->proc->stop(0, 9); // SIGKILL. return Exited::fromRunningWorker($this); } public function isRunning(): bool { - $status = $this->getStatus(); - return (bool)(($status['running']) ?? false); + return $this->proc->isRunning(); } public function getId(): string @@ -231,32 +110,35 @@ public function getRequiredResourcesIds(): array public function getExitCode(): int { - return $this->getStatus()['exitcode'] ?? -1; + return $this->proc->getExitCode() ?? -1; } public function getStartTime(): float { - return $this->startTime; + return $this->proc->getStartTime(); } /** * @return resource + * @throws ProcessException */ public function getStdoutStream() { - return $this->stdout; + return $this->proc->getStdoutStream(); } /** * @return resource + * @throws ProcessException */ - public function getStderrStream() + public function getStdErrStream() { - return $this->stderr; + return $this->proc->getStdErrStream(); } /** * @param resource $stream + * @throws ProcessException */ public function readStream($stream): int { @@ -264,34 +146,17 @@ public function readStream($stream): int return 0; } - $buffer = ''; - do { - $read = fread($stream, 2048); - if ($read === false) { - throw new RuntimeException('Failed to read from stream.'); - } - $buffer .= $read; - } while (!feof($stream)); - - if ($stream === $this->stdout) { + if ($stream === $this->getStdoutStream()) { + $buffer = $this->proc->getIncrementalOutput(); $this->stdoutBuffer .= $buffer; } else { + $buffer = $this->proc->getIncrementalErrorOutput(); $this->stderrBuffer .= $buffer; } return strlen($buffer); } - public function readStdoutStream(): int - { - return $this->readStream($this->stdout); - } - - public function readStderrStream(): int - { - return $this->readStream($this->stderr); - } - public function getStderrBuffer(): string { $this->extractReturnValueFromStderr(); diff --git a/src/Process/Worker/WorkerProcess.php b/src/Process/Worker/WorkerProcess.php new file mode 100644 index 000000000..239c7faf8 --- /dev/null +++ b/src/Process/Worker/WorkerProcess.php @@ -0,0 +1,61 @@ +stdoutStream)) { + return $this->stdoutStream; + } + + $stream = Property::readPrivate($this, 'stdout'); + + if (!is_resource($stream)) { + throw new ProcessException('Could not get the process stdout stream.'); + } + + $this->stdoutStream = $stream; + + return $stream; + } + + /** + * @return resource + * @throws ProcessException + */ + public function getStdErrStream() + { + if (is_resource($this->stderrStream)) { + return $this->stderrStream; + } + + $stream = Property::readPrivate($this, 'stderr'); + + if (!is_resource($stream)) { + throw new ProcessException('Could not get the process stderr stream.'); + } + + $this->stderrStream = $stream; + + return $stream; + } +} diff --git a/src/Utils/ChromedriverInstaller.php b/src/Utils/ChromedriverInstaller.php index e78e83063..e53615471 100644 --- a/src/Utils/ChromedriverInstaller.php +++ b/src/Utils/ChromedriverInstaller.php @@ -7,6 +7,7 @@ use lucatume\WPBrowser\Exceptions\RuntimeException; use Symfony\Component\Console\Output\NullOutput; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Process\Process; class ChromedriverInstaller { @@ -39,17 +40,17 @@ public function __construct( ) { $this->output = $output ?? new NullOutput(); - $platform = $platform ?? $this->detectPlatform(); + $platform = $platform ?? $this->detectPlatform(); $this->platform = $this->checkPlatform($platform); $this->output->writeln("Platform: $platform"); - $binary = $binary ?? $this->detectBinary(); + $binary = $binary ?? $this->detectBinary(); $this->binary = $this->checkBinary($binary); $this->output->writeln("Binary: $binary"); - $version = $version ?? $this->detectVersion(); + $version = $version ?? $this->detectVersion(); $this->milestone = $this->checkVersion($version); $this->output->writeln("Version: $version"); @@ -62,14 +63,14 @@ public function install(string $dir = null): string { if ($dir === null) { global $_composer_bin_dir; - $dir = $_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)) { + if (!is_dir($dir)) { throw new InvalidArgumentException( "The directory $dir does not exist.", self::ERR_DESTINATION_NOT_DIR @@ -78,10 +79,10 @@ public function install(string $dir = null): string $this->output->writeln("Fetching Chromedriver version URL ..."); - $downloadUrl = $this->fetchChromedriverVersionUrl(); + $downloadUrl = $this->fetchChromedriverVersionUrl(); $zipFilePathname = rtrim(sys_get_temp_dir(), '\\/') . '/' . basename($downloadUrl); - if (is_file($zipFilePathname) && ! unlink($zipFilePathname)) { + if (is_file($zipFilePathname) && !unlink($zipFilePathname)) { throw new RuntimeException( "Could not remove existing zip file $zipFilePathname", self::ERR_REMOVE_EXISTING_ZIP_FILE @@ -93,7 +94,7 @@ public function install(string $dir = null): string $executableFileName = $dir . '/' . $this->getExecutableFileName(); - if (is_file($executableFileName) && ! unlink($executableFileName)) { + if (is_file($executableFileName) && !unlink($executableFileName)) { throw new RuntimeException( "Could not remove existing executable file $executableFileName", self::ERR_REMOVE_EXISTING_BINARY @@ -102,7 +103,7 @@ public function install(string $dir = null): string $extractedPath = Zip::extractTo($zipFilePathname, sys_get_temp_dir()); - if (! rename( + if (!rename( "$extractedPath/chromedriver-$this->platform/" . $this->getExecutableFileName(), $executableFileName )) { @@ -129,15 +130,35 @@ public function install(string $dir = null): string */ private function detectVersion(): string { - $chromeVersion = exec($this->binary . ' --version'); - if (! ( $chromeVersion && is_string($chromeVersion) )) { + $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 ); } - return $chromeVersion; + $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]); } /** @@ -147,7 +168,7 @@ private function detectPlatform(): string { // Return one of `linux64`, `mac-arm64`,`mac-x64`, `win32`, `win64`. $system = php_uname('s'); - $arch = php_uname('m'); + $arch = php_uname('m'); if ($system === 'Darwin') { if ($arch === 'arm64') { @@ -162,7 +183,7 @@ private function detectPlatform(): string } if ($system === 'Windows NT') { - if ($arch === 'x86_64') { + if (str_contains($arch, '64')) { return 'win64'; } @@ -179,13 +200,13 @@ private function detectPlatform(): string */ private function checkPlatform(mixed $platform): string { - if (! ( is_string($platform) && in_array($platform, [ + if (!(is_string($platform) && in_array($platform, [ 'linux64', 'mac-arm64', 'mac-x64', 'win32', 'win64' - ]) )) { + ]))) { throw new RuntimeException( 'Invalid platform, supported platforms are: linux64, mac-arm64, mac-x64, win32, win64.', self::ERR_UNSUPPORTED_PLATFORM @@ -196,6 +217,23 @@ private function checkPlatform(mixed $platform): string return $platform; } + private function detectWindowsBinaryPath(): string + { + $candidates = [ + getenv('ProgramFiles') . '\\\\Google\\\\Chrome\\\\Application\\\\chrome.exe', + getenv('ProgramFiles(x86)') . '\\\\Google\\\\Chrome\\\\Application\\\\chrome.exe', + getenv('LOCALAPPDATA') . '\\\\Google\\\\Chrome\\\\Application\\\\chrome.exe' + ]; + + foreach ($candidates as $candidate) { + if (is_file($candidate)) { + return $candidate; + } + } + + return $candidate; + } + /** * @throws RuntimeException */ @@ -203,15 +241,15 @@ private function detectBinary(): string { return match ($this->platform) { 'linux64' => '/usr/bin/google-chrome', - 'mac-x64', 'mac-arm64' => '/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome', - 'win32', 'win64' => 'C:\\\\Program Files (x86)\\\\Google\\\\Chrome\\\\Application\\\\chrome.exe' + 'mac-x64', 'mac-arm64' => '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + 'win32', 'win64' => $this->detectWindowsBinaryPath() }; } private function checkBinary(mixed $binary): string { // Replace escaped spaces with spaces to check the binary. - if (! ( is_string($binary) && is_executable(str_replace('\ ', ' ', $binary)) )) { + if (!(is_string($binary) && is_executable(str_replace('\ ', ' ', $binary)))) { throw new RuntimeException( "Invalid Chrome binary: not executable or not existing.", self::ERR_INVALID_BINARY @@ -224,7 +262,7 @@ private function checkBinary(mixed $binary): string private function checkVersion(mixed $version): string { $matches = []; - if (! ( is_string($version) && preg_match('/^.*?(?\d+)(\.\d+\.\d+\.\d+)*$/', $version, $matches) )) { + if (!(is_string($version) && preg_match('/^.*?(?\d+)(\.\d+\.\d+\.\d+)*$/', $version, $matches))) { throw new RuntimeException( "Invalid Chrome version: must be in the form X.Y.Z.W.", self::ERR_INVALID_VERSION_FORMAT @@ -252,16 +290,16 @@ private function fetchChromedriverVersionUrl(): string $decoded = json_decode($milestoneDownloads, true, 512, JSON_THROW_ON_ERROR); - if (! ( + if (!( is_array($decoded) && isset($decoded['milestones']) && is_array($decoded['milestones']) - && isset($decoded['milestones'][ $this->milestone ]) - && is_array($decoded['milestones'][ $this->milestone ]) - && isset($decoded['milestones'][ $this->milestone ]['downloads']) - && is_array($decoded['milestones'][ $this->milestone ]['downloads']) - && isset($decoded['milestones'][ $this->milestone ]['downloads']['chromedriver']) - && is_array($decoded['milestones'][ $this->milestone ]['downloads']['chromedriver']) + && isset($decoded['milestones'][$this->milestone]) + && is_array($decoded['milestones'][$this->milestone]) + && isset($decoded['milestones'][$this->milestone]['downloads']) + && is_array($decoded['milestones'][$this->milestone]['downloads']) + && isset($decoded['milestones'][$this->milestone]['downloads']['chromedriver']) + && is_array($decoded['milestones'][$this->milestone]['downloads']['chromedriver']) )) { throw new RuntimeException( 'Failed to decode known good Chrome and Chromedriver versions with downloads.', @@ -269,8 +307,8 @@ private function fetchChromedriverVersionUrl(): string ); } - foreach ($decoded['milestones'][ $this->milestone ]['downloads']['chromedriver'] as $download) { - if (! ( + foreach ($decoded['milestones'][$this->milestone]['downloads']['chromedriver'] as $download) { + if (!( is_array($download) && isset($download['platform'], $download['url']) && is_string($download['platform'])