diff --git a/README.md b/README.md index 1e0ac57..61cfbe6 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@
-This package provides the `rr` console command for PHP projects that run on [RoadRunner](https://roadrunner.dev): it downloads the RoadRunner server binary and `protoc-gen-php-grpc` plugin built for your environment and generates a starter `.rr.yaml` configuration. +This package provides the `rr` console command for PHP projects that run on [RoadRunner](https://roadrunner.dev): it downloads the RoadRunner server binary and the gRPC code generation tools (`buf`, `protoc`, `protoc-gen-php-grpc`) built for your environment and generates a starter `.rr.yaml` configuration. > [!NOTE] > Version 3.x of `roadrunner/cli` targets RoadRunner v3: `rr get` downloads RoadRunner 3 by default. For RoadRunner 2025.x, use [`spiral/roadrunner-cli`](https://packagist.org/packages/spiral/roadrunner-cli) 2.x. @@ -54,6 +54,25 @@ Then start the server: See the [RoadRunner documentation](https://docs.roadrunner.dev) for the configuration reference. +### gRPC tools + +Generating gRPC code with `buf generate` takes three binaries: [`buf`](https://buf.build), `protoc` (buf runs its built-in PHP generator) and `protoc-gen-php-grpc`. Download all of them into the current directory (`--location` picks another one): + +```bash +vendor/bin/rr get-grpc-tools +``` + +``` + google/protobuf v5.36.2 -> protoc ^36.0 + + Versions: + - buf: ^1 + - protoc: ^36.0 + - protoc-gen-php-grpc: 3.* +``` + +The protoc version follows the protobuf runtime of the project, since the generated code is only guaranteed to work with the runtime of the same protoc major: the PHP runtime minor is the protoc major (`google/protobuf` 4.33.x takes protoc 33.x, 5.36.x takes 36.x; up to 3.20 both shared one version). The runtime is `ext-protobuf` when it is loaded in the PHP running `rr`, or else the `google/protobuf` package installed in the project (the nearest directory up from the working one with a `composer.json`); when the two need different protoc majors, a warning names both, as the project may run on a PHP without the extension; without either, or when its version does not tell the protoc major (e.g. a branch), a protoc already in the location stays whatever its version, or else the latest one is installed. `--protoc`, `--buf` and `--plugin` (for `protoc-gen-php-grpc`) take a version constraint each, e.g. `--protoc=^33.0`. A `buf` or `protoc` already in the location stays when its `--version` satisfies the constraint and is replaced otherwise, also when it does not run on this machine; `protoc-gen-php-grpc` is downloaded on every run. + ## Commands - `get-binary` (or `get`) - allows to install the latest version of the RoadRunner compatible with @@ -70,19 +89,24 @@ See the [RoadRunner documentation](https://docs.roadrunner.dev) for the configur Available presets: `web` (contains plugins `http`, `jobs`). Use `--no-config` to skip the configuration file. - `make-config` - creates the `.rr.yaml` configuration file without downloading the binary. Accepts the same `plugin` and `preset` options. -- `download-protoc-binary` - allows to install the latest version of the `protoc-gen-php-grpc` file compatible with - your environment (operating system, processor architecture, runtime, etc...). +- `get-grpc-tools` - installs `buf`, `protoc` and `protoc-gen-php-grpc`, the binaries `buf generate` needs to + generate gRPC code, see [gRPC tools](#grpc-tools). The versions default to buf 1.x, the protoc major the project's + protobuf runtime supports and `protoc-gen-php-grpc` of the RoadRunner line `get-binary` installs; `--buf`, `--protoc` + and `--plugin` override them. Without asking, it keeps a binary already in the location only when its version + satisfies the constraint, see [gRPC tools](#grpc-tools). +- `download-protoc-binary` - **deprecated**, use `get-grpc-tools`. Installs only the `protoc-gen-php-grpc` plugin, + not protoc itself. - `versions` - displays a list of available RoadRunner binary versions. ### Common options -The binary commands accept `--filter` (`-f`, version constraint; for `get-binary` and `download-protoc-binary` `*` means `>=3.0 <2000` and any other `||` alternative without an upper bound gets `<2000`, so the calendar releases have to be named explicitly, e.g. `2025.*` or `>=2025`), `--stability` (`-s`), `--os` (`-o`), `--arch` (`-a`) and `--location` (`-l`, target directory) options. `get-binary` and `download-protoc-binary` download the binaries with [DLoad](https://github.com/php-internal/dload); a project's `dload.xml` does not affect them. +The binary commands accept `--stability` (`-s`), `--os` (`-o`) and `--arch` (`-a`) options. `get-binary`, `download-protoc-binary` and `versions` take the version constraint as `--filter` (`-f`), `get-grpc-tools` as `--buf`, `--protoc` and `--plugin`. For `get-binary` and `download-protoc-binary` `*` means `>=3.0 <2000` and any other `||` alternative without an upper bound gets `<2000`, so the calendar releases have to be named explicitly, e.g. `2025.*` or `>=2025`; `get-grpc-tools` applies the same to `--plugin` and takes `--buf`/`--protoc` as they are. `get-binary`, `get-grpc-tools` and `download-protoc-binary` also accept `--location` (`-l`, target directory, the current one by default) and download the binaries with [DLoad](https://github.com/php-internal/dload); a project's `dload.xml` does not affect them. On a musl host such as Alpine they install the musl build of Linux on their own, even with `--os=linux`; `--os=unknown-musl` forces the musl build from any host. DLoad comes with its own `vendor/bin/dload` and keeps a version registry in the user cache: `~/.cache/dload`, `$XDG_CACHE_HOME/dload` when that is set, `%LOCALAPPDATA%\dload\cache` on Windows. `DLOAD_CACHE_DIR` moves it, `DLOAD_CACHE_TTL=0` turns it off. -Releases are fetched from the GitHub API at `RR_GITHUB_API_URL`, `https://api.github.com` by default. For `get-binary` and `download-protoc-binary` it is either `https://api.github.com` or a GitHub Enterprise Server API URL ending with `/api/v3` (e.g. `https://ghe.example.com/api/v3`); `versions` also accepts a mirror. +Releases are fetched from the GitHub API at `RR_GITHUB_API_URL`, `https://api.github.com` by default. For `get-binary`, `get-grpc-tools` and `download-protoc-binary` it is either `https://api.github.com` or a GitHub Enterprise Server API URL ending with `/api/v3` (e.g. `https://ghe.example.com/api/v3`), which then has to serve `bufbuild/buf` and `protocolbuffers/protobuf` for `get-grpc-tools` too; `versions` also accepts a mirror. All commands pick the API token the same way, to avoid the rate limits: diff --git a/bin/rr b/bin/rr index a1441e4..7e14585 100644 --- a/bin/rr +++ b/bin/rr @@ -12,6 +12,7 @@ declare(strict_types=1); use Spiral\RoadRunner\Console\DownloadProtocBinaryCommand; use Spiral\RoadRunner\Console\GetBinaryCommand; +use Spiral\RoadRunner\Console\GetGrpcToolsCommand; use Spiral\RoadRunner\Console\MakeConfigCommand; use Spiral\RoadRunner\Console\VersionsCommand; use Spiral\RoadRunner\Version; @@ -72,12 +73,14 @@ if (\method_exists($app, 'addCommand')) { $app->addCommand(new GetBinaryCommand()); $app->addCommand(new VersionsCommand()); $app->addCommand(new DownloadProtocBinaryCommand()); + $app->addCommand(new GetGrpcToolsCommand()); $app->addCommand(new MakeConfigCommand()); } else { // For Symfony 7.3 and lower $app->add(new GetBinaryCommand()); $app->add(new VersionsCommand()); $app->add(new DownloadProtocBinaryCommand()); + $app->add(new GetGrpcToolsCommand()); $app->add(new MakeConfigCommand()); } diff --git a/composer.json b/composer.json index b01e79b..7fddfa1 100644 --- a/composer.json +++ b/composer.json @@ -41,7 +41,7 @@ "php": ">=8.2", "composer-runtime-api": "^2.0", "composer/semver": "^3.4", - "internal/dload": "^1.19.0", + "internal/dload": "^1.19.1", "roadrunner/worker": "^3.8", "symfony/console": "^6.4 || ^7.0 || ^8.0", "symfony/http-client": "^6.4.17 || ^7.2 || ^8.0", diff --git a/src/DownloadProtocBinaryCommand.php b/src/DownloadProtocBinaryCommand.php index 51300b7..65f46a8 100644 --- a/src/DownloadProtocBinaryCommand.php +++ b/src/DownloadProtocBinaryCommand.php @@ -6,6 +6,7 @@ use Spiral\RoadRunner\Console\Environment\OperatingSystem; use Spiral\RoadRunner\Console\Internal\Downloader\DLoadDownloader; +use Spiral\RoadRunner\Console\Internal\Installation\ExistingBinary; use Spiral\RoadRunner\Console\Internal\Option\ArchitectureOption; use Spiral\RoadRunner\Console\Internal\Option\InstallationLocationOption; use Spiral\RoadRunner\Console\Internal\Option\OperatingSystemOption; @@ -14,7 +15,6 @@ use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Console\Style\StyleInterface; use Symfony\Component\Console\Style\SymfonyStyle; /** @@ -46,13 +46,14 @@ public function __construct(?string $name = null, ?DLoadDownloader $downloader = #[\Override] public function getDescription(): string { - return 'Install or update protoc-gen-php-grpc binary'; + return 'Install or update protoc-gen-php-grpc binary (deprecated, use get-grpc-tools)'; } #[\Override] public function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); + $io->warning('download-protoc-binary is deprecated: use get-grpc-tools, which also installs protoc and buf.'); $target = $this->location->get($input, $io); $os = $this->os->get($input, $io); @@ -67,7 +68,7 @@ public function execute(InputInterface $input, OutputInterface $output): int $binary = $target . ($os === OperatingSystem::OS_WINDOWS ? '/protoc-gen-php-grpc.exe' : '/protoc-gen-php-grpc'); - if (!$this->checkExisting($binary, $io)) { + if (!ExistingBinary::mayInstall($binary, 'protoc-gen-php-grpc', $io)) { $io->warning('protoc-gen-php-grpc has not been installed'); return 1; @@ -84,19 +85,4 @@ public function execute(InputInterface $input, OutputInterface $output): int output: $output, ); } - - private function checkExisting(string $binary, StyleInterface $io): bool - { - if (\is_file($binary)) { - $io->warning('protoc-gen-php-grpc binary file already exists!'); - - if (!$io->confirm('Do you want overwrite it?', false)) { - $io->note('Skipping protoc-gen-php-grpc installation...'); - - return false; - } - } - - return true; - } } diff --git a/src/GetBinaryCommand.php b/src/GetBinaryCommand.php index 726e2c9..ea0c226 100644 --- a/src/GetBinaryCommand.php +++ b/src/GetBinaryCommand.php @@ -15,6 +15,7 @@ use Spiral\RoadRunner\Console\Internal\Configuration\Generator; use Spiral\RoadRunner\Console\Internal\Configuration\Plugins; use Spiral\RoadRunner\Console\Internal\Downloader\DLoadDownloader; +use Spiral\RoadRunner\Console\Internal\Installation\ExistingBinary; use Spiral\RoadRunner\Console\Internal\Option\ArchitectureOption; use Spiral\RoadRunner\Console\Internal\Option\InstallationLocationOption; use Spiral\RoadRunner\Console\Internal\Option\OperatingSystemOption; @@ -43,6 +44,8 @@ public function __construct(?string $name = null, ?DLoadDownloader $downloader = { $this->downloader = $downloader ?? new DLoadDownloader(); parent::__construct($name ?? 'get-binary'); + // Not left to Symfony's abbreviation lookup: `get` also abbreviates get-grpc-tools + $this->getName() === 'get' or $this->setAliases(['get']); $this->os = new OperatingSystemOption($this); $this->arch = new ArchitectureOption($this); @@ -80,7 +83,7 @@ public function execute(InputInterface $input, OutputInterface $output): int $binary = $target . ($os === OperatingSystem::OS_WINDOWS ? '/rr.exe' : '/rr'); $installed = false; - if ($this->checkExisting($binary, $io)) { + if (ExistingBinary::mayInstall($binary, 'RoadRunner', $io)) { $code = $this->downloader->download( software: 'rr', constraint: $this->version->get($input, $io), @@ -180,19 +183,4 @@ private function installConfig(string $to, InputInterface $in, StyleInterface $i \file_put_contents($to, $config); } - - private function checkExisting(string $binary, StyleInterface $io): bool - { - if (\is_file($binary)) { - $io->warning('RoadRunner binary file already exists!'); - - if (! $io->confirm('Do you want overwrite it?', false)) { - $io->note('Skipping RoadRunner installation...'); - - return false; - } - } - - return true; - } } diff --git a/src/GetGrpcToolsCommand.php b/src/GetGrpcToolsCommand.php new file mode 100644 index 0000000..425a6f2 --- /dev/null +++ b/src/GetGrpcToolsCommand.php @@ -0,0 +1,239 @@ + */ + private \Closure $runtimes; + + /** + * @param (\Closure(): list)|null $runtimes Finds the protobuf runtimes of the project, the one + * in use first; {@see ProtobufRuntime::detect()} in the project found up from the working directory + * by default. + */ + public function __construct(?string $name = null, ?DLoadDownloader $downloader = null, ?\Closure $runtimes = null) + { + $this->downloader = $downloader ?? new DLoadDownloader(); + $this->runtimes = $runtimes ?? static fn(): array => ProtobufRuntime::detect(ProtobufRuntime::projectDir(\getcwd() ?: '.')); + parent::__construct($name ?? 'get-grpc-tools'); + + $this->os = new OperatingSystemOption($this); + $this->arch = new ArchitectureOption($this); + $this->location = new InstallationLocationOption($this); + $this->stability = new StabilityOption($this); + } + + #[\Override] + public function getDescription(): string + { + return 'Install or update buf, protoc and protoc-gen-php-grpc to generate gRPC code'; + } + + #[\Override] + public function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $target = $this->location->get($input, $io); + $os = $this->os->get($input, $io); + $arch = $this->arch->get($input, $io); + $stability = $this->stability->get($input, $io); + + $output->writeln(''); + $output->writeln(' Environment:'); + $output->writeln(\sprintf(' - Stability: %s', $stability)); + $output->writeln(\sprintf(' - Operating System: %s', $os)); + $output->writeln(\sprintf(' - Architecture: %s', $arch)); + $output->writeln(''); + + [$protoc, $derivedFrom] = $this->protocConstraint($input, $io); + + $tools = [ + 'buf' => $this->option($input, 'buf'), + 'protoc' => $protoc, + 'protoc-gen-php-grpc' => $this->option($input, 'plugin'), + ]; + + $output->writeln(' Versions:'); + foreach ($tools as $tool => $constraint) { + $output->writeln(\sprintf(' - %-20s %s', $tool . ':', $constraint)); + } + $output->writeln(''); + + $failed = []; + foreach ($tools as $tool => $constraint) { + // Without force DLoad keeps a binary whose `--version` satisfies the constraint and replaces any other, + // so protoc-gen-php-grpc, which has no version command, is downloaded every time + $code = $this->downloader->download( + software: $tool, + constraint: $constraint, + stability: $stability, + os: $os, + arch: $arch, + location: $target, + force: false, + output: $output, + ); + + if ($code === self::SUCCESS) { + continue; + } + + $failed[] = $tool; + if ($tool === 'protoc' && $derivedFrom !== null) { + $io->error(\sprintf( + 'Could not install protoc %s, the version %s %s supports. ' + . 'If no protoc release matches it, choose one with --protoc=.', + $constraint, + $derivedFrom->name, + $derivedFrom->version, + )); + } + } + + if ($failed !== []) { + $io->error('Not installed: ' . \implode(', ', $failed)); + + return self::FAILURE; + } + + $io->success('gRPC tools are ready in ' . $target); + + return self::SUCCESS; + } + + #[\Override] + protected function configure(): void + { + $this->addOption( + 'protoc', + null, + InputOption::VALUE_REQUIRED, + 'Version constraint of protoc; by default the protoc major the installed google/protobuf or ext-protobuf supports', + ); + + $this->addOption( + 'plugin', + null, + InputOption::VALUE_REQUIRED, + 'Version constraint of protoc-gen-php-grpc', + RoadRunnerVersion::constraint(), + ); + + $this->addOption( + 'buf', + null, + InputOption::VALUE_REQUIRED, + 'Version constraint of buf', + self::BUF_CONSTRAINT, + ); + } + + /** + * @param 'buf'|'plugin'|'protoc' $name + * @return ($name is 'protoc' ? non-empty-string|null : non-empty-string) The option's default when it is + * empty or not given; protoc has none. + */ + private function option(InputInterface $input, string $name): ?string + { + /** @var mixed $value */ + $value = $input->getOption($name); + if (\is_string($value) && $value !== '') { + return $value; + } + + /** @var non-empty-string|null */ + return $this->getDefinition()->getOption($name)->getDefault(); + } + + /** + * @return array{non-empty-string, ProtobufRuntime|null} The constraint and the runtime it is derived from. + */ + private function protocConstraint(InputInterface $input, SymfonyStyle $io): array + { + $option = $this->option($input, 'protoc'); + if ($option !== null) { + return [$option, null]; + } + + $runtimes = ($this->runtimes)(); + $runtime = $runtimes[0] ?? null; + if ($runtime === null) { + $io->warning(\sprintf( + 'Neither %s nor %s is installed: keeping an installed protoc or installing the latest. Choose a version with --protoc=.', + ProtobufRuntime::PACKAGE, + ProtobufRuntime::EXTENSION, + )); + + return [self::ANY_VERSION, null]; + } + + $constraint = $runtime->protocConstraint(); + if ($constraint === null) { + $io->warning(\sprintf( + 'Can not tell the protoc version for %s %s: keeping an installed protoc or installing the latest. Choose a version with --protoc=.', + $runtime->name, + $runtime->version, + )); + + return [self::ANY_VERSION, null]; + } + + $io->writeln(\sprintf(' %s %s -> protoc %s', $runtime->name, $runtime->version, $constraint)); + $io->newLine(); + + foreach (\array_slice($runtimes, 1) as $other) { + $otherConstraint = $other->protocConstraint(); + if ($otherConstraint !== null && $otherConstraint !== $constraint) { + $io->warning(\sprintf( + '%s %s (protoc %s) is used, while the project has %s %s (protoc %s); choose with --protoc=.', + $runtime->name, + $runtime->version, + $constraint, + $other->name, + $other->version, + $otherConstraint, + )); + } + } + + return [$constraint, $runtime]; + } +} diff --git a/src/Internal/Downloader/DLoadDownloader.php b/src/Internal/Downloader/DLoadDownloader.php index 78c5741..1cec9a5 100644 --- a/src/Internal/Downloader/DLoadDownloader.php +++ b/src/Internal/Downloader/DLoadDownloader.php @@ -48,13 +48,18 @@ final class DLoadDownloader */ private const ANY_VERSION = '>=3.0 ' . self::CALENDAR_BOUND; + /** + * Software released from the RoadRunner repository, the only one with calendar versions. + */ + private const ROADRUNNER_SOFTWARE = ['rr', 'protoc-gen-php-grpc']; + /** * DLoad serves the API of a GitHub Enterprise Server at `scheme://host[:port]/api/v3`, so no other path can be mapped. */ private const ENTERPRISE_API_PATH = '/api/v3'; /** - * The RoadRunner entries of DLoad's built-in registry, with the server `scheme://host[:port]` to substitute. + * The entries of DLoad's built-in registry this package downloads, with the server `scheme://host[:port]` to substitute. * * DLoad takes a self-hosted GitHub from the repository `uri` itself: `scheme://host[:port]/owner/repo`. */ @@ -70,6 +75,14 @@ final class DLoadDownloader + + + + + + + + XML; @@ -90,8 +103,9 @@ public function __construct(?Command $get = null, ?string $githubApiUrl = null) } /** - * @param non-empty-string $software DLoad software alias, e.g. "rr" or "protoc-gen-php-grpc". - * @param string $constraint Composer version constraint; "*" or empty means 3.x or a later semver line. + * @param non-empty-string $software DLoad software alias, e.g. "rr", "protoc-gen-php-grpc", "protoc" or "buf". + * @param string $constraint Composer version constraint; "*" or empty means any version, which for + * the RoadRunner software is 3.x or a later semver line. * @param string $stability Minimum stability, e.g. "stable" or "beta". * @param bool $force Overwrite the binary if it already exists in the location. * @@ -110,7 +124,11 @@ public function download( $config = $this->createConfig(); $input = new ArrayInput([ - 'software' => [$software . self::versionSuffix($constraint, $stability)], + 'software' => [$software . self::versionSuffix( + $constraint, + $stability, + \in_array($software, self::ROADRUNNER_SOFTWARE, true), + )], '--config' => $config ?? self::CONFIG, '--path' => $location, '--os' => $os, @@ -133,20 +151,22 @@ public function download( /** * DLoad ignores `--stability` once a version is given: the stability has to be a part of the constraint. + * + * @param bool $calendar Whether the software has calendar releases an unbounded constraint must stay below. */ - private static function versionSuffix(string $constraint, string $stability): string + private static function versionSuffix(string $constraint, string $stability, bool $calendar): string { [$version, $explicitStability] = \explode('@', \trim($constraint), 2) + [1 => null]; $version = \trim($version); if ($version === '' || $version === '*') { - $version = self::ANY_VERSION; + $version = $calendar ? self::ANY_VERSION : '*'; + } elseif ($calendar) { + // DLoad has no parentheses, so the bound goes into each alternative + $alternatives = \preg_split('/\s*\|\|?\s*/', $version) ?: [$version]; + $version = \implode(' || ', \array_map(self::boundAlternative(...), $alternatives)); } - // DLoad has no parentheses, so the bound goes into each alternative - $alternatives = \preg_split('/\s*\|\|?\s*/', $version) ?: [$version]; - $version = \implode(' || ', \array_map(self::boundAlternative(...), $alternatives)); - return ':' . $version . '@' . ($explicitStability ?? $stability); } diff --git a/src/Internal/Environment/ProtobufRuntime.php b/src/Internal/Environment/ProtobufRuntime.php new file mode 100644 index 0000000..c66cf46 --- /dev/null +++ b/src/Internal/Environment/ProtobufRuntime.php @@ -0,0 +1,209 @@ + Every runtime found, the one in use first. + */ + public static function detect(string $projectDir): array + { + return \array_values(\array_filter([self::fromExtension(), self::fromPackage($projectDir)])); + } + + /** + * Finds the project root as Composer does: the nearest directory up from the working one with a `composer.json` + * (or the file named by `COMPOSER`), looking neither in the user home nor in the filesystem root. + * + * @return string The working directory itself when none has the file. + */ + public static function projectDir(string $workingDir): string + { + $file = self::composerFile(); + if (self::isAbsolute($file)) { + return $workingDir; + } + + $home = \realpath(self::userDir() ?? '/'); + for ($dir = $workingDir; \dirname($dir) !== $dir && \realpath($dir) !== $home; $dir = \dirname($dir)) { + if (\is_file($dir . '/' . $file)) { + return $dir; + } + } + + return $workingDir; + } + + /** + * Looks for google/protobuf in the project's `installed.json`, or in the Composer installation running this tool + * when there is no project manifest at all. + * + * Run from a global installation or a PHAR, {@see InstalledVersions} knows only the packages installed next + * to this tool, so a project with its own manifest is never matched against them, even before `composer install`. + * The file is decoded rather than `installed.php` included: the project may be any directory above the working one. + */ + public static function fromPackage(string $projectDir): ?self + { + $file = self::vendorDir($projectDir) . '/composer/installed.json'; + + return match (true) { + \is_file($file) => self::fromInstalledFile($file), + \is_file(self::manifest($projectDir)) => null, + default => self::fromInstalledVersions(), + }; + } + + public static function fromInstalledVersions(): ?self + { + if (! InstalledVersions::isInstalled(self::PACKAGE)) { + return null; + } + + $version = InstalledVersions::getPrettyVersion(self::PACKAGE); + + return $version === null ? null : new self(self::PACKAGE, $version); + } + + public static function fromExtension(): ?self + { + $version = \phpversion('protobuf'); + + return $version === false ? null : new self(self::EXTENSION, $version); + } + + /** + * The protoc versions whose generated code this runtime supports: the same protoc major. + * + * Since protobuf 21 the PHP runtime minor is the protoc major (3.21.x and protoc 21.x, 4.33.x and 33.x, + * 5.36.x and 36.x), and the runtime major is bumped on its own. Up to 3.20 both shared one version. + * The constraint also checks an installed protoc, and protoc 21.x reports itself as `libprotoc 3.21.x`. + * + * @return non-empty-string|null Null when the version is not a release number, e.g. a branch. + */ + public function protocConstraint(): ?string + { + if (\preg_match('/^v?(\d+)\.(\d+)/', \trim($this->version), $m) !== 1) { + return null; + } + + [, $major, $minor] = \array_map(\intval(...), $m); + + return match (true) { + $minor === 21 => '^21.0 || ~3.21.0', + $minor > 20 => \sprintf('^%d.0', $minor), + $major === 3 => \sprintf('~3.%d.0', $minor), + default => null, + }; + } + + /** + * Reads both the Composer 2 `{"packages": [...]}` layout and the Composer 1 bare list of packages. + */ + private static function fromInstalledFile(string $file): ?self + { + /** @var mixed $installed */ + $installed = \json_decode((string) @\file_get_contents($file), true); + /** @var mixed $packages */ + $packages = \is_array($installed) ? $installed['packages'] ?? $installed : null; + + /** @var mixed $package */ + foreach (\is_array($packages) ? $packages : [] as $package) { + if (\is_array($package) && ($package['name'] ?? null) === self::PACKAGE) { + /** @var mixed $version */ + $version = $package['version'] ?? null; + + return \is_string($version) ? new self(self::PACKAGE, $version) : null; + } + } + + return null; + } + + /** + * Resolves `vendor-dir` as Composer does, expanding a leading `~/`; `{$home}` (the Composer home) is left as is. + */ + private static function vendorDir(string $projectDir): string + { + $vendor = Environment::get('COMPOSER_VENDOR_DIR'); + + if ($vendor === null || $vendor === '') { + /** @var mixed $composer */ + $composer = \json_decode((string) @\file_get_contents(self::manifest($projectDir)), true); + /** @var mixed $vendor */ + $vendor = \is_array($composer) ? $composer['config']['vendor-dir'] ?? null : null; + $vendor = \is_string($vendor) && $vendor !== '' ? $vendor : 'vendor'; + } + + $user = self::userDir(); + if ($user !== null && \preg_match('#^~[/\\\\]#', $vendor) === 1) { + $vendor = $user . \substr($vendor, 1); + } + + return self::isAbsolute($vendor) ? $vendor : $projectDir . '/' . $vendor; + } + + /** + * @return non-empty-string The manifest named by `COMPOSER`, relative to the project unless absolute. + */ + private static function composerFile(): string + { + $file = \trim(Environment::get('COMPOSER') ?? ''); + + return $file === '' ? 'composer.json' : $file; + } + + private static function manifest(string $projectDir): string + { + $file = self::composerFile(); + + return self::isAbsolute($file) ? $file : $projectDir . '/' . $file; + } + + private static function userDir(): ?string + { + $dir = Environment::get('HOME'); + $dir = $dir === null || $dir === '' ? Environment::get('USERPROFILE') : $dir; + + return $dir === null || $dir === '' ? null : $dir; + } + + private static function isAbsolute(string $path): bool + { + return \preg_match('#^(?:[a-z]:)?[/\\\\]#i', $path) === 1; + } +} diff --git a/src/Internal/Installation/ExistingBinary.php b/src/Internal/Installation/ExistingBinary.php new file mode 100644 index 0000000..e25ca30 --- /dev/null +++ b/src/Internal/Installation/ExistingBinary.php @@ -0,0 +1,44 @@ +warning(\sprintf('%s binary file already exists!', $name)); + + if ($io->confirm('Do you want overwrite it?', false)) { + return true; + } + + $io->note(\sprintf('Skipping %s installation...', $name)); + + return false; + } +} diff --git a/tests/Acceptance/GrpcToolsTest.php b/tests/Acceptance/GrpcToolsTest.php new file mode 100644 index 0000000..4b8a56a --- /dev/null +++ b/tests/Acceptance/GrpcToolsTest.php @@ -0,0 +1,170 @@ +installProtobuf($runtime); + + $result = $this->rr()->run(self::ARGS); + + Assert::same($result->exitCode, 0, (string) $result); + Assert::string($result->output())->contains("google/protobuf $runtime -> protoc $constraint"); + Assert::same(\trim($this->dir->read('protoc')), "fake protoc $protoc linux-x86_64"); + Assert::same(\trim($this->dir->read('buf')), 'fake buf 1.75.0 Linux x86_64'); + Assert::same(\trim($this->dir->read('protoc-gen-php-grpc')), 'fake rr 3.4.0 linux amd64'); + Assert::same(FakeGitHub::downloads(), [ + 'buf-Linux-x86_64.tar.gz', + "protoc-$protoc-linux-x86_64.zip", + 'protoc-gen-php-grpc-3.4.0-linux-amd64.tar.gz', + ]); + } + + public function installsWindowsBinaries(): void + { + $this->installProtobuf('4.33.1'); + + $result = $this->rr()->run(['get-grpc-tools', '--os=windows', '--arch=amd64']); + + Assert::same($result->exitCode, 0, (string) $result); + Assert::same(\trim($this->dir->read('protoc.exe')), 'fake protoc 33.6 win64'); + Assert::same(\trim($this->dir->read('buf.exe')), 'fake buf 1.75.0 Windows x86_64'); + Assert::same(\trim($this->dir->read('protoc-gen-php-grpc.exe')), 'fake rr 3.4.0 windows amd64'); + Assert::same(FakeGitHub::downloads(), [ + 'buf-Windows-x86_64.zip', + 'protoc-33.6-win64.zip', + 'protoc-gen-php-grpc-3.4.0-windows-amd64.zip', + ]); + } + + public function installsMacosBinaries(): void + { + $this->installProtobuf('5.36.2'); + + $result = $this->rr()->run(['get-grpc-tools', '--os=darwin', '--arch=arm64']); + + Assert::same($result->exitCode, 0, (string) $result); + Assert::same(\trim($this->dir->read('buf')), 'fake buf 1.75.0 Darwin arm64'); + Assert::same(\trim($this->dir->read('protoc')), 'fake protoc 36.2 osx-universal_binary'); + Assert::same(\trim($this->dir->read('protoc-gen-php-grpc')), 'fake rr 3.4.0 darwin arm64'); + Assert::same(FakeGitHub::downloads(), [ + 'buf-Darwin-arm64.tar.gz', + 'protoc-36.2-osx-universal_binary.zip', + 'protoc-gen-php-grpc-3.4.0-darwin-arm64.tar.gz', + ]); + } + + public function installsLatestProtocWithoutRuntime(): void + { + self::skipWithExtension(); + + $result = $this->rr()->run(self::ARGS); + + Assert::same($result->exitCode, 0, (string) $result); + Assert::string($result->output())->contains('Neither google/protobuf nor ext-protobuf is installed'); + Assert::same(\trim($this->dir->read('protoc')), 'fake protoc 36.2 linux-x86_64'); + } + + public function takesVersionsFromOptions(): void + { + $this->installProtobuf('5.36.2'); + + $result = $this->rr()->run([...self::ARGS, '--protoc=^33.0', '--buf=*', '--plugin=2025.*']); + + Assert::same($result->exitCode, 0, (string) $result); + Assert::string($result->output())->notContains('google/protobuf'); + Assert::same(\trim($this->dir->read('protoc')), 'fake protoc 33.6 linux-x86_64'); + Assert::same(\trim($this->dir->read('buf')), 'fake buf 2.0.0 Linux x86_64'); + Assert::same(\trim($this->dir->read('protoc-gen-php-grpc')), 'fake rr 2025.1.5 linux amd64'); + } + + public function suggestsProtocOptionWhenNoReleaseMatchesRuntime(): void + { + $this->installProtobuf('5.40.0'); + + $result = $this->rr()->run(self::ARGS); + + Assert::same($result->exitCode, 1, (string) $result); + Assert::string($result->output())->contains('--protoc='); + Assert::false($this->dir->has('protoc')); + Assert::true($this->dir->has('buf')); + Assert::true($this->dir->has('protoc-gen-php-grpc')); + } + + public function replacesExistingBinaryWithoutVersion(): void + { + $this->installProtobuf('4.33.1'); + $this->dir->write('protoc', "old protoc\n"); + + $result = $this->rr()->run(self::ARGS); + + Assert::same($result->exitCode, 0, (string) $result); + Assert::string($result->output())->notContains('already exists')->contains('gRPC tools are ready'); + Assert::same(\trim($this->dir->read('protoc')), 'fake protoc 33.6 linux-x86_64'); + Assert::same(FakeGitHub::downloads(), [ + 'buf-Linux-x86_64.tar.gz', + 'protoc-33.6-linux-x86_64.zip', + 'protoc-gen-php-grpc-3.4.0-linux-amd64.tar.gz', + ]); + } + + /** + * protoc 21.x reports the version of the 3.21.x runtime. + */ + #[DataSet(['4.33.1', '33.6'])] + #[DataSet(['3.21.12', '3.21.12'], 'protoc 21')] + public function keepsExistingBinaryOfMatchingVersion(string $runtime, string $protoc): void + { + \PHP_OS_FAMILY === 'Windows' and throw new SkipTest('A shell script stands in for the linux protoc'); + $this->installProtobuf($runtime); + $script = "#!/bin/sh\necho libprotoc $protoc\n"; + $this->dir->write('protoc', $script); + \chmod($this->dir->file('protoc'), 0755); + + $result = $this->rr()->run(self::ARGS); + + Assert::same($result->exitCode, 0, (string) $result); + Assert::same($this->dir->read('protoc'), $script); + Assert::same(FakeGitHub::downloads(), ['buf-Linux-x86_64.tar.gz', 'protoc-gen-php-grpc-3.4.0-linux-amd64.tar.gz']); + } + + /** + * The rr subprocess prefers a loaded ext-protobuf to the google/protobuf of the project. + */ + private static function skipWithExtension(): void + { + \extension_loaded('protobuf') and throw new SkipTest('ext-protobuf is loaded'); + } + + private function installProtobuf(string $version): void + { + self::skipWithExtension(); + \mkdir($this->dir->file('vendor/composer'), 0777, true); + $this->dir->write('vendor/composer/installed.json', \json_encode([ + 'packages' => [['name' => 'google/protobuf', 'version' => $version]], + ])); + } +} diff --git a/tests/Acceptance/Server/releases-buf.json b/tests/Acceptance/Server/releases-buf.json new file mode 100644 index 0000000..b86f6cb --- /dev/null +++ b/tests/Acceptance/Server/releases-buf.json @@ -0,0 +1,194 @@ +[ + { + "tag_name": "v2.0.0", + "prerelease": false, + "assets": [ + "buf-Darwin-arm64", + "buf-Darwin-arm64.tar.gz", + "buf-Darwin-x86_64", + "buf-Darwin-x86_64.tar.gz", + "buf-FreeBSD-arm64", + "buf-FreeBSD-arm64.tar.gz", + "buf-FreeBSD-x86_64", + "buf-FreeBSD-x86_64.tar.gz", + "buf-Linux-aarch64", + "buf-Linux-aarch64.tar.gz", + "buf-Linux-armv7", + "buf-Linux-armv7.tar.gz", + "buf-Linux-ppc64le", + "buf-Linux-ppc64le.tar.gz", + "buf-Linux-riscv64", + "buf-Linux-riscv64.tar.gz", + "buf-Linux-s390x", + "buf-Linux-s390x.tar.gz", + "buf-Linux-x86_64", + "buf-Linux-x86_64.tar.gz", + "buf-OpenBSD-arm64", + "buf-OpenBSD-arm64.tar.gz", + "buf-OpenBSD-x86_64", + "buf-OpenBSD-x86_64.tar.gz", + "buf-Windows-arm64.exe", + "buf-Windows-arm64.zip", + "buf-Windows-x86_64.exe", + "buf-Windows-x86_64.zip", + "protoc-gen-buf-breaking-Darwin-arm64", + "protoc-gen-buf-breaking-Darwin-x86_64", + "protoc-gen-buf-breaking-FreeBSD-arm64", + "protoc-gen-buf-breaking-FreeBSD-x86_64", + "protoc-gen-buf-breaking-Linux-aarch64", + "protoc-gen-buf-breaking-Linux-armv7", + "protoc-gen-buf-breaking-Linux-ppc64le", + "protoc-gen-buf-breaking-Linux-riscv64", + "protoc-gen-buf-breaking-Linux-s390x", + "protoc-gen-buf-breaking-Linux-x86_64", + "protoc-gen-buf-breaking-OpenBSD-arm64", + "protoc-gen-buf-breaking-OpenBSD-x86_64", + "protoc-gen-buf-breaking-Windows-arm64.exe", + "protoc-gen-buf-breaking-Windows-x86_64.exe", + "protoc-gen-buf-lint-Darwin-arm64", + "protoc-gen-buf-lint-Darwin-x86_64", + "protoc-gen-buf-lint-FreeBSD-arm64", + "protoc-gen-buf-lint-FreeBSD-x86_64", + "protoc-gen-buf-lint-Linux-aarch64", + "protoc-gen-buf-lint-Linux-armv7", + "protoc-gen-buf-lint-Linux-ppc64le", + "protoc-gen-buf-lint-Linux-riscv64", + "protoc-gen-buf-lint-Linux-s390x", + "protoc-gen-buf-lint-Linux-x86_64", + "protoc-gen-buf-lint-OpenBSD-arm64", + "protoc-gen-buf-lint-OpenBSD-x86_64", + "protoc-gen-buf-lint-Windows-arm64.exe", + "protoc-gen-buf-lint-Windows-x86_64.exe", + "sha256.txt", + "sha256.txt.minisig" + ] + }, + { + "tag_name": "v1.75.0", + "prerelease": false, + "assets": [ + "buf-Darwin-arm64", + "buf-Darwin-arm64.tar.gz", + "buf-Darwin-x86_64", + "buf-Darwin-x86_64.tar.gz", + "buf-FreeBSD-arm64", + "buf-FreeBSD-arm64.tar.gz", + "buf-FreeBSD-x86_64", + "buf-FreeBSD-x86_64.tar.gz", + "buf-Linux-aarch64", + "buf-Linux-aarch64.tar.gz", + "buf-Linux-armv7", + "buf-Linux-armv7.tar.gz", + "buf-Linux-ppc64le", + "buf-Linux-ppc64le.tar.gz", + "buf-Linux-riscv64", + "buf-Linux-riscv64.tar.gz", + "buf-Linux-s390x", + "buf-Linux-s390x.tar.gz", + "buf-Linux-x86_64", + "buf-Linux-x86_64.tar.gz", + "buf-OpenBSD-arm64", + "buf-OpenBSD-arm64.tar.gz", + "buf-OpenBSD-x86_64", + "buf-OpenBSD-x86_64.tar.gz", + "buf-Windows-arm64.exe", + "buf-Windows-arm64.zip", + "buf-Windows-x86_64.exe", + "buf-Windows-x86_64.zip", + "protoc-gen-buf-breaking-Darwin-arm64", + "protoc-gen-buf-breaking-Darwin-x86_64", + "protoc-gen-buf-breaking-FreeBSD-arm64", + "protoc-gen-buf-breaking-FreeBSD-x86_64", + "protoc-gen-buf-breaking-Linux-aarch64", + "protoc-gen-buf-breaking-Linux-armv7", + "protoc-gen-buf-breaking-Linux-ppc64le", + "protoc-gen-buf-breaking-Linux-riscv64", + "protoc-gen-buf-breaking-Linux-s390x", + "protoc-gen-buf-breaking-Linux-x86_64", + "protoc-gen-buf-breaking-OpenBSD-arm64", + "protoc-gen-buf-breaking-OpenBSD-x86_64", + "protoc-gen-buf-breaking-Windows-arm64.exe", + "protoc-gen-buf-breaking-Windows-x86_64.exe", + "protoc-gen-buf-lint-Darwin-arm64", + "protoc-gen-buf-lint-Darwin-x86_64", + "protoc-gen-buf-lint-FreeBSD-arm64", + "protoc-gen-buf-lint-FreeBSD-x86_64", + "protoc-gen-buf-lint-Linux-aarch64", + "protoc-gen-buf-lint-Linux-armv7", + "protoc-gen-buf-lint-Linux-ppc64le", + "protoc-gen-buf-lint-Linux-riscv64", + "protoc-gen-buf-lint-Linux-s390x", + "protoc-gen-buf-lint-Linux-x86_64", + "protoc-gen-buf-lint-OpenBSD-arm64", + "protoc-gen-buf-lint-OpenBSD-x86_64", + "protoc-gen-buf-lint-Windows-arm64.exe", + "protoc-gen-buf-lint-Windows-x86_64.exe", + "sha256.txt", + "sha256.txt.minisig" + ] + }, + { + "tag_name": "v1.74.0", + "prerelease": false, + "assets": [ + "buf-Darwin-arm64", + "buf-Darwin-arm64.tar.gz", + "buf-Darwin-x86_64", + "buf-Darwin-x86_64.tar.gz", + "buf-FreeBSD-arm64", + "buf-FreeBSD-arm64.tar.gz", + "buf-FreeBSD-x86_64", + "buf-FreeBSD-x86_64.tar.gz", + "buf-Linux-aarch64", + "buf-Linux-aarch64.tar.gz", + "buf-Linux-armv7", + "buf-Linux-armv7.tar.gz", + "buf-Linux-ppc64le", + "buf-Linux-ppc64le.tar.gz", + "buf-Linux-riscv64", + "buf-Linux-riscv64.tar.gz", + "buf-Linux-s390x", + "buf-Linux-s390x.tar.gz", + "buf-Linux-x86_64", + "buf-Linux-x86_64.tar.gz", + "buf-OpenBSD-arm64", + "buf-OpenBSD-arm64.tar.gz", + "buf-OpenBSD-x86_64", + "buf-OpenBSD-x86_64.tar.gz", + "buf-Windows-arm64.exe", + "buf-Windows-arm64.zip", + "buf-Windows-x86_64.exe", + "buf-Windows-x86_64.zip", + "protoc-gen-buf-breaking-Darwin-arm64", + "protoc-gen-buf-breaking-Darwin-x86_64", + "protoc-gen-buf-breaking-FreeBSD-arm64", + "protoc-gen-buf-breaking-FreeBSD-x86_64", + "protoc-gen-buf-breaking-Linux-aarch64", + "protoc-gen-buf-breaking-Linux-armv7", + "protoc-gen-buf-breaking-Linux-ppc64le", + "protoc-gen-buf-breaking-Linux-riscv64", + "protoc-gen-buf-breaking-Linux-s390x", + "protoc-gen-buf-breaking-Linux-x86_64", + "protoc-gen-buf-breaking-OpenBSD-arm64", + "protoc-gen-buf-breaking-OpenBSD-x86_64", + "protoc-gen-buf-breaking-Windows-arm64.exe", + "protoc-gen-buf-breaking-Windows-x86_64.exe", + "protoc-gen-buf-lint-Darwin-arm64", + "protoc-gen-buf-lint-Darwin-x86_64", + "protoc-gen-buf-lint-FreeBSD-arm64", + "protoc-gen-buf-lint-FreeBSD-x86_64", + "protoc-gen-buf-lint-Linux-aarch64", + "protoc-gen-buf-lint-Linux-armv7", + "protoc-gen-buf-lint-Linux-ppc64le", + "protoc-gen-buf-lint-Linux-riscv64", + "protoc-gen-buf-lint-Linux-s390x", + "protoc-gen-buf-lint-Linux-x86_64", + "protoc-gen-buf-lint-OpenBSD-arm64", + "protoc-gen-buf-lint-OpenBSD-x86_64", + "protoc-gen-buf-lint-Windows-arm64.exe", + "protoc-gen-buf-lint-Windows-x86_64.exe", + "sha256.txt", + "sha256.txt.minisig" + ] + } +] diff --git a/tests/Acceptance/Server/releases-protobuf.json b/tests/Acceptance/Server/releases-protobuf.json new file mode 100644 index 0000000..400c160 --- /dev/null +++ b/tests/Acceptance/Server/releases-protobuf.json @@ -0,0 +1,61 @@ +[ + { + "tag_name": "v37.0-rc1", + "prerelease": true, + "assets": [ + "protobuf-37.0-rc1.tar.gz", + "protoc-37.0-rc-1-linux-x86_64.zip", + "protoc-37.0-rc-1-win64.zip" + ] + }, + { + "tag_name": "v36.2", + "prerelease": false, + "assets": [ + "protobuf-36.2.tar.gz", + "protobuf-36.2.zip", + "protoc-36.2-linux-aarch_64.zip", + "protoc-36.2-linux-x86_32.zip", + "protoc-36.2-linux-x86_64.zip", + "protoc-36.2-osx-universal_binary.zip", + "protoc-36.2-win32.zip", + "protoc-36.2-win64.zip" + ] + }, + { + "tag_name": "v36.1", + "prerelease": false, + "assets": [ + "protobuf-36.1.tar.gz", + "protoc-36.1-linux-x86_64.zip", + "protoc-36.1-win64.zip" + ] + }, + { + "tag_name": "v33.6", + "prerelease": false, + "assets": [ + "protobuf-33.6.tar.gz", + "protoc-33.6-linux-x86_64.zip", + "protoc-33.6-win64.zip" + ] + }, + { + "tag_name": "v21.12", + "prerelease": false, + "assets": [ + "protobuf-all-21.12.tar.gz", + "protoc-21.12-linux-x86_64.zip", + "protoc-21.12-win64.zip" + ] + }, + { + "tag_name": "v3.20.3", + "prerelease": false, + "assets": [ + "protobuf-all-3.20.3.tar.gz", + "protoc-3.20.3-linux-x86_64.zip", + "protoc-3.20.3-win64.zip" + ] + } +] diff --git a/tests/Acceptance/Server/releases.json b/tests/Acceptance/Server/releases.json index ceac630..7c2f868 100644 --- a/tests/Acceptance/Server/releases.json +++ b/tests/Acceptance/Server/releases.json @@ -23,8 +23,10 @@ "tag_name": "v3.4.0", "prerelease": false, "assets": [ + "protoc-gen-php-grpc-3.4.0-darwin-arm64.tar.gz", "protoc-gen-php-grpc-3.4.0-linux-amd64.tar.gz", "protoc-gen-php-grpc-3.4.0-linux-arm64.tar.gz", + "protoc-gen-php-grpc-3.4.0-windows-amd64.zip", "roadrunner-3.4.0-linux-amd64.deb", "roadrunner-3.4.0-linux-arm64.deb", "roadrunner-3.4.0-linux-amd64.tar.gz", diff --git a/tests/Acceptance/Server/router.php b/tests/Acceptance/Server/router.php index f272446..0d67dab 100644 --- a/tests/Acceptance/Server/router.php +++ b/tests/Acceptance/Server/router.php @@ -5,16 +5,19 @@ * * A server plays one scenario, named by the FAKE_GITHUB_SCENARIO environment variable: DLoad takes a GitHub * Enterprise Server from the repository uri `scheme://host:port/owner/repo`, so a scenario can not be a part of the path. - * - `github` — releases from releases.json, paginated by `per_page` like GitHub (30 by default, 100 at most); + * - `github` — releases of RoadRunner from releases.json, of protobuf from releases-protobuf.json and of buf + * from releases-buf.json (its v2.0.0 is made up), paginated by `per_page` like GitHub + * (30 by default, 100 at most); * - `paged` — the same releases, 2 per page whatever `per_page` asks for; * - `rate-limit` — every API request fails with the 403 GitHub sends when the rate limit is exhausted; * - `missing-asset` — releases are listed, but every download is a 404; * - `redirect` — every request is redirected to the same path of the server named by FAKE_GITHUB_REDIRECT_TO. * * Like GitHub Enterprise Server, the API is served under `/api/v3`: release lists at - * `/api/v3/repos/roadrunner-server/roadrunner/releases`, assets at + * `/api/v3/repos///releases`, assets at * `/download//`. Archives are built on request: `roadrunner---/rr[.exe]` - * holding the text `fake rr `, so a test can tell which asset ended up installed. + * holding the text `fake rr `, `bin/protoc[.exe]` holding `fake protoc ` and + * `buf/bin/buf[.exe]` holding `fake buf `, so a test can tell which asset ended up installed. * * Each request is appended to the JSON Lines file named by the FAKE_GITHUB_LOG environment variable. * @@ -50,23 +53,35 @@ echo $body; }; -$archive = static function (string $name): ?string { +$archive = static function (string $name, string $tag): ?string { + $files = []; $pattern = '/^(roadrunner|protoc-gen-php-grpc)-(.+)-(linux|darwin|freebsd|windows)-(amd64|arm64)\.(tar\.gz|zip)$/'; - if (\preg_match($pattern, $name, $m) !== 1) { + if (\preg_match($pattern, $name, $m) === 1) { + [, $software, $version, $os, $arch, $format] = $m; + $binary = ($software === 'roadrunner' ? 'rr' : $software) . ($os === 'windows' ? '.exe' : ''); + $root = "$software-$version-$os-$arch"; + $files = ["$root/$binary" => "fake rr $version $os $arch\n", "$root/LICENSE" => "MIT\n"]; + } elseif (\preg_match('/^protoc-(.+?)-((?:linux|osx)-[a-z0-9_]+|win32|win64)\.(zip)$/', $name, $m) === 1) { + [, $version, $platform, $format] = $m; + $binary = 'protoc' . (\str_starts_with($platform, 'win') ? '.exe' : ''); + $files = ["bin/$binary" => "fake protoc $version $platform\n", 'include/google/protobuf/empty.proto' => "\n"]; + } elseif (\preg_match('/^buf-(Linux|Darwin|Windows)-([a-z0-9_]+)\.(tar\.gz|zip)$/', $name, $m) === 1) { + [, $os, $arch, $format] = $m; + $version = \ltrim($tag, 'v'); + $binary = 'buf' . ($os === 'Windows' ? '.exe' : ''); + $files = ["buf/bin/$binary" => "fake buf $version $os $arch\n", 'buf/LICENSE' => "Apache-2.0\n"]; + } else { return null; } - [, $software, $version, $os, $arch, $format] = $m; - $binary = ($software === 'roadrunner' ? 'rr' : $software) . ($os === 'windows' ? '.exe' : ''); - $root = "$software-$version-$os-$arch"; - $dir = \sys_get_temp_dir() . '/rr-cli-fake-github'; @\mkdir($dir, 0777, true); $file = $dir . '/archive-' . \bin2hex(\random_bytes(8)) . ($format === 'zip' ? '.zip' : '.tar'); $phar = new \PharData($file); - $phar->addFromString("$root/$binary", "fake rr $version $os $arch\n"); - $phar->addFromString("$root/LICENSE", "MIT\n"); + foreach ($files as $entry => $content) { + $phar->addFromString($entry, $content); + } if ($format === 'tar.gz') { $phar->compress(\Phar::GZ); } @@ -100,8 +115,15 @@ return true; } -if (\implode('/', $segments) === 'api/v3/repos/roadrunner-server/roadrunner/releases') { - $releases = \json_decode((string) \file_get_contents(__DIR__ . '/releases.json'), true); +$repository = \preg_match('#^api/v3/repos/([^/]+/[^/]+)/releases$#', \implode('/', $segments), $m) === 1 ? $m[1] : ''; +$releaseFile = [ + 'roadrunner-server/roadrunner' => 'releases.json', + 'protocolbuffers/protobuf' => 'releases-protobuf.json', + 'bufbuild/buf' => 'releases-buf.json', +][$repository] ?? null; + +if ($releaseFile !== null) { + $releases = \json_decode((string) \file_get_contents(__DIR__ . '/' . $releaseFile), true); $perPage = $scenario === 'paged' ? 2 : \min(100, \max(1, (int) ($query['per_page'] ?? 30))); $page = \max(1, (int) ($query['page'] ?? 1)); $last = (int) \ceil(\count($releases) / $perPage); @@ -126,8 +148,9 @@ } $link = static fn(int $to, string $rel): string => \sprintf( - '<%s/repos/roadrunner-server/roadrunner/releases?per_page=%d&page=%d>; rel="%s"', + '<%s/repos/%s/releases?per_page=%d&page=%d>; rel="%s"', $api, + $repository, $perPage, $to, $rel, @@ -151,7 +174,7 @@ } if (($segments[0] ?? '') === 'download' && \count($segments) === 3 && $scenario !== 'missing-asset') { - $content = $archive($segments[2]) ?? \str_repeat("\0", 1024); + $content = $archive($segments[2], $segments[1]) ?? \str_repeat("\0", 1024); $respond(200, $content, [ 'Content-Type' => 'application/octet-stream', 'Content-Length' => (string) \strlen($content), diff --git a/tests/Acceptance/VersionsTest.php b/tests/Acceptance/VersionsTest.php index efd0786..3b752cd 100644 --- a/tests/Acceptance/VersionsTest.php +++ b/tests/Acceptance/VersionsTest.php @@ -26,7 +26,7 @@ public function listsStableReleasesNewestFirst(): void Assert::same($result->exitCode, 0, (string) $result); Assert::same(self::releases($result->stdout), ['3.4.0', '3.2.0', '3.1.0', '2025.1.5', '2.12.3']); Assert::string($result->stdout) - ->matchesRegex('/^\s*3\.4\.0\s+stable\s+✓\s+\(7\)\s+✓\s*$/mu') + ->matchesRegex('/^\s*3\.4\.0\s+stable\s+✓\s+\(9\)\s+✓\s*$/mu') ->matchesRegex('/^\s*2025\.1\.5\s+stable\s+.*\(reason: incompatible version\)/mu'); Assert::same(\count(FakeGitHub::releaseRequests()), 1); } diff --git a/tests/Unit/DownloadProtocBinaryCommandTest.php b/tests/Unit/DownloadProtocBinaryCommandTest.php index afb1dc0..76f97e3 100644 --- a/tests/Unit/DownloadProtocBinaryCommandTest.php +++ b/tests/Unit/DownloadProtocBinaryCommandTest.php @@ -37,7 +37,7 @@ public function describesItself(): void $command = new DownloadProtocBinaryCommand(); Assert::same($command->getName(), 'download-protoc-binary'); - Assert::same($command->getDescription(), 'Install or update protoc-gen-php-grpc binary'); + Assert::same($command->getDescription(), 'Install or update protoc-gen-php-grpc binary (deprecated, use get-grpc-tools)'); } public function definesEnvironmentOptions(): void @@ -65,6 +65,7 @@ public function installsBinaryThroughDLoad(int $exitCode): void Assert::same($get->calls[0]->getOption('os'), 'darwin'); Assert::same($get->calls[0]->getOption('arch'), 'arm64'); Assert::string($tester->getDisplay()) + ->contains('download-protoc-binary is deprecated: use get-grpc-tools') ->contains('Version: 2025.1.*') ->contains('Operating System: darwin') ->contains('Architecture: arm64'); diff --git a/tests/Unit/GetBinaryCommandTest.php b/tests/Unit/GetBinaryCommandTest.php index 28d22a5..f737a8a 100644 --- a/tests/Unit/GetBinaryCommandTest.php +++ b/tests/Unit/GetBinaryCommandTest.php @@ -48,9 +48,16 @@ public function describesItself(): void $command = new GetBinaryCommand(); Assert::same($command->getName(), 'get-binary'); + Assert::same($command->getAliases(), ['get']); Assert::same($command->getDescription(), 'Install or update RoadRunner binary'); } + public function keepsGetAliasUnderAnotherName(): void + { + Assert::same((new GetBinaryCommand('install'))->getAliases(), ['get']); + Assert::same((new GetBinaryCommand('get'))->getAliases(), []); + } + public function keepsOptions(): void { Assert::array(\array_keys((new GetBinaryCommand())->getDefinition()->getOptions())) diff --git a/tests/Unit/GetGrpcToolsCommandTest.php b/tests/Unit/GetGrpcToolsCommandTest.php new file mode 100644 index 0000000..2bd2129 --- /dev/null +++ b/tests/Unit/GetGrpcToolsCommandTest.php @@ -0,0 +1,267 @@ +dir = TempDirectory::create(); + } + + #[AfterTest] + public function removeDirectory(): void + { + TempDirectory::remove($this->dir); + } + + public function describesItself(): void + { + $command = new GetGrpcToolsCommand(); + + Assert::same($command->getName(), 'get-grpc-tools'); + Assert::string($command->getDescription())->contains('buf')->contains('protoc')->contains('protoc-gen-php-grpc'); + } + + public function definesOptions(): void + { + $definition = (new GetGrpcToolsCommand())->getDefinition(); + + Assert::array(\array_keys($definition->getOptions())) + ->sameElementsAs(['os', 'arch', 'location', 'stability', 'protoc', 'plugin', 'buf']); + Assert::same($definition->getOption('buf')->getDefault(), '^1'); + Assert::same($definition->getOption('plugin')->getDefault(), '3.*'); + Assert::null($definition->getOption('protoc')->getDefault()); + } + + public function installsAllToolsThroughDLoad(): void + { + $get = new DLoadGetSpy(); + $tester = $this->tester($get, new ProtobufRuntime(ProtobufRuntime::PACKAGE, '5.36.2')); + + $status = $tester->execute($this->input(), ['interactive' => false]); + + Assert::same($status, 0, $tester->getDisplay()); + Assert::same( + \array_map(static fn($input): array => $input->getArgument('software'), $get->calls), + [['buf:^1@stable'], ['protoc:^36.0@stable'], ['protoc-gen-php-grpc:3.*@stable']], + ); + foreach ($get->calls as $call) { + Assert::same($call->getOption('path'), $this->dir); + Assert::same($call->getOption('os'), 'darwin'); + Assert::same($call->getOption('arch'), 'arm64'); + Assert::false($call->getOption('force')); + } + Assert::string($tester->getDisplay()) + ->contains('google/protobuf 5.36.2 -> protoc ^36.0') + ->contains('protoc: ^36.0') + ->contains('gRPC tools are ready'); + } + + public function derivesProtocFromExtension(): void + { + $get = new DLoadGetSpy(); + $tester = $this->tester($get, new ProtobufRuntime(ProtobufRuntime::EXTENSION, '4.33.1')); + + $tester->execute($this->input(), ['interactive' => false]); + + Assert::same($get->calls[1]->getArgument('software'), ['protoc:^33.0@stable']); + Assert::string($tester->getDisplay())->contains('ext-protobuf 4.33.1 -> protoc ^33.0'); + } + + public function warnsWhenProjectPackageNeedsAnotherProtoc(): void + { + $get = new DLoadGetSpy(); + $tester = $this->tester($get, [ + new ProtobufRuntime(ProtobufRuntime::EXTENSION, '4.29.0'), + new ProtobufRuntime(ProtobufRuntime::PACKAGE, 'v5.36.2'), + ]); + + $status = $tester->execute($this->input(), ['interactive' => false]); + + Assert::same($status, 0, $tester->getDisplay()); + Assert::same($get->calls[1]->getArgument('software'), ['protoc:^29.0@stable']); + Assert::string($this->display($tester))->contains( + 'ext-protobuf 4.29.0 (protoc ^29.0) is used, while the project has google/protobuf v5.36.2 (protoc ^36.0); ' + . 'choose with --protoc=.', + ); + } + + public function keepsQuietWhenRuntimesNeedSameProtoc(): void + { + $get = new DLoadGetSpy(); + $tester = $this->tester($get, [ + new ProtobufRuntime(ProtobufRuntime::EXTENSION, '4.33.1'), + new ProtobufRuntime(ProtobufRuntime::PACKAGE, 'v4.33.4'), + ]); + + $status = $tester->execute($this->input(), ['interactive' => false]); + + Assert::same($status, 0, $tester->getDisplay()); + Assert::same($get->calls[1]->getArgument('software'), ['protoc:^33.0@stable']); + Assert::string($tester->getDisplay())->notContains('[WARNING]')->notContains('is used, while'); + } + + public function takesVersionsFromOptions(): void + { + $get = new DLoadGetSpy(); + $tester = $this->tester($get, static fn(): never => throw new \LogicException('Must not detect the runtime')); + + $status = $tester->execute($this->input([ + '--protoc' => '^30.0', + '--plugin' => '3.0.*', + '--buf' => '1.50.*', + '--stability' => 'beta', + ]), ['interactive' => false]); + + Assert::same($status, 0, $tester->getDisplay()); + Assert::same( + \array_map(static fn($input): array => $input->getArgument('software'), $get->calls), + [['buf:1.50.*@beta'], ['protoc:^30.0@beta'], ['protoc-gen-php-grpc:3.0.*@beta']], + ); + } + + public function fallsBackToDefaultsForEmptyOptions(): void + { + $get = new DLoadGetSpy(); + $tester = $this->tester($get, new ProtobufRuntime(ProtobufRuntime::PACKAGE, '5.36.2')); + + $status = $tester->execute($this->input([ + '--protoc' => '', + '--plugin' => '', + '--buf' => '', + ]), ['interactive' => false]); + + Assert::same($status, 0, $tester->getDisplay()); + Assert::same( + \array_map(static fn($input): array => $input->getArgument('software'), $get->calls), + [['buf:^1@stable'], ['protoc:^36.0@stable'], [\sprintf('protoc-gen-php-grpc:%s@stable', RoadRunnerVersion::constraint())]], + ); + } + + #[DataSet([null, 'Neither google/protobuf nor ext-protobuf is installed'], 'no runtime')] + #[DataSet(['dev-main', 'Can not tell the protoc version for google/protobuf dev-main'], 'branch')] + public function acceptsAnyProtocWithWarning(?string $version, string $warning): void + { + $get = new DLoadGetSpy(); + $runtime = $version === null ? null : new ProtobufRuntime(ProtobufRuntime::PACKAGE, $version); + $tester = $this->tester($get, $runtime); + + $status = $tester->execute($this->input(), ['interactive' => false]); + + Assert::same($status, 0, $tester->getDisplay()); + Assert::same($get->calls[1]->getArgument('software'), ['protoc:*@stable']); + Assert::string($this->display($tester))->contains($warning) + ->contains('keeping an installed protoc or installing the latest') + ->contains('--protoc='); + } + + public function suggestsProtocOptionWhenDerivedVersionFails(): void + { + $get = new DLoadGetSpy(failing: ['protoc']); + $tester = $this->tester($get, new ProtobufRuntime(ProtobufRuntime::PACKAGE, '5.37.0')); + + $status = $tester->execute($this->input(), ['interactive' => false]); + + Assert::same($status, 1); + Assert::count($get->calls, 3); + Assert::string($this->display($tester)) + ->contains('Could not install protoc ^37.0, the version google/protobuf 5.37.0 supports') + ->contains('--protoc=') + ->contains('Not installed: protoc'); + Assert::true(\is_file($this->dir . '/buf')); + Assert::true(\is_file($this->dir . '/protoc-gen-php-grpc')); + } + + public function reportsFailureWithoutHintForExplicitVersion(): void + { + $get = new DLoadGetSpy(failing: ['buf', 'protoc']); + $tester = $this->tester($get, null); + + $status = $tester->execute($this->input(['--protoc' => '^99.0']), ['interactive' => false]); + + Assert::same($status, 1); + Assert::string($this->display($tester)) + ->notContains('Could not install protoc') + ->contains('Not installed: buf, protoc'); + } + + #[DataSet(['linux', ''])] + #[DataSet(['windows', '.exe'])] + public function leavesExistingBinariesToDLoadWithoutAsking(string $os, string $extension): void + { + foreach (['buf', 'protoc', 'protoc-gen-php-grpc'] as $tool) { + \file_put_contents($this->dir . '/' . $tool . $extension, 'old binary'); + } + $get = new DLoadGetSpy(); + $tester = $this->tester($get, new ProtobufRuntime(ProtobufRuntime::PACKAGE, '4.33.1')); + + // An interactive run without answers: a prompt would fail it + $status = $tester->execute($this->input(['--os' => $os])); + + Assert::same($status, 0, $tester->getDisplay()); + Assert::same( + \array_map(static fn($input): array => $input->getArgument('software'), $get->calls), + [['buf:^1@stable'], ['protoc:^33.0@stable'], ['protoc-gen-php-grpc:3.*@stable']], + ); + foreach ($get->calls as $call) { + Assert::false($call->getOption('force')); + } + Assert::string($tester->getDisplay())->notContains('already exists')->contains('gRPC tools are ready'); + } + + /** + * @param array $input + * @return array + */ + private function input(array $input = []): array + { + return $input + [ + '--location' => $this->dir, + '--os' => 'darwin', + '--arch' => 'arm64', + '--plugin' => '3.*', + ]; + } + + /** + * @param ProtobufRuntime|list|\Closure|null $runtimes + */ + private function tester(DLoadGetSpy $get, ProtobufRuntime|array|\Closure|null $runtimes): CommandTester + { + $detect = match (true) { + $runtimes instanceof \Closure => $runtimes, + $runtimes instanceof ProtobufRuntime => static fn(): array => [$runtimes], + default => static fn(): array => $runtimes ?? [], + }; + + return new CommandTester(new GetGrpcToolsCommand(downloader: new DLoadDownloader($get), runtimes: $detect)); + } + + /** + * The display with the block messages unwrapped. + */ + private function display(CommandTester $tester): string + { + return (string) \preg_replace('/\s*\n\s*(?:!\s+)?/', ' ', $tester->getDisplay()); + } +} diff --git a/tests/Unit/Internal/Downloader/DLoadDownloaderTest.php b/tests/Unit/Internal/Downloader/DLoadDownloaderTest.php index 56ac0a9..b1404b9 100644 --- a/tests/Unit/Internal/Downloader/DLoadDownloaderTest.php +++ b/tests/Unit/Internal/Downloader/DLoadDownloaderTest.php @@ -75,10 +75,10 @@ public function usesBundledEmptyConfig(): void Assert::same(\simplexml_load_file($config)?->count(), 0); } - #[DataSet(['https://ghe.example.com/api/v3', 'https://ghe.example.com/roadrunner-server/roadrunner'])] - #[DataSet(['https://GHE.example.com/api/v3/', 'https://ghe.example.com/roadrunner-server/roadrunner'], 'trailing slash')] - #[DataSet(['http://127.0.0.1:8080/api/v3', 'http://127.0.0.1:8080/roadrunner-server/roadrunner'], 'scheme and port are kept')] - public function pointsRegistryAtEnterpriseServer(string $url, string $uri): void + #[DataSet(['https://ghe.example.com/api/v3', 'https://ghe.example.com'])] + #[DataSet(['https://GHE.example.com/api/v3/', 'https://ghe.example.com'], 'trailing slash')] + #[DataSet(['http://127.0.0.1:8080/api/v3', 'http://127.0.0.1:8080'], 'scheme and port are kept')] + public function pointsRegistryAtEnterpriseServer(string $url, string $server): void { $get = new DLoadGetSpy(); @@ -94,7 +94,12 @@ public function pointsRegistryAtEnterpriseServer(string $url, string $uri): void isset($software->repository['server']), ]; } - Assert::same($repositories, ['rr' => [$uri, false], 'protoc-gen-php-grpc' => [$uri, false]]); + Assert::same($repositories, [ + 'rr' => [$server . '/roadrunner-server/roadrunner', false], + 'protoc-gen-php-grpc' => [$server . '/roadrunner-server/roadrunner', false], + 'protoc' => [$server . '/protocolbuffers/protobuf', false], + 'buf' => [$server . '/bufbuild/buf', false], + ]); Assert::false(\is_file((string) $get->calls[0]->getOption('config'))); } @@ -206,6 +211,25 @@ public function keepsBoundedOrCalendarFilter(string $constraint): void Assert::same($get->calls[0]->getArgument('software'), ['rr:' . $constraint . '@beta']); } + /** + * Only the RoadRunner repository has calendar releases to keep out. + */ + #[DataSet(['buf', '*', 'buf:*@stable'], 'wildcard')] + #[DataSet(['protoc', '', 'protoc:*@stable'], 'empty constraint')] + #[DataSet(['protoc', '*@rc', 'protoc:*@rc'], 'explicit stability')] + #[DataSet(['protoc', '>=30.0', 'protoc:>=30.0@stable'], 'lower bound')] + #[DataSet(['buf', '^1 || >=2.1', 'buf:^1 || >=2.1@stable'], 'alternatives')] + #[DataSet(['protoc-gen-php-grpc', '>=3.0', 'protoc-gen-php-grpc:>=3.0 <2000@stable'], 'RoadRunner plugin')] + public function boundsOnlyRoadRunnerSoftware(string $software, string $constraint, string $expected): void + { + $get = new DLoadGetSpy(); + + (new DLoadDownloader($get)) + ->download($software, $constraint, 'stable', 'linux', 'amd64', $this->dir, false, new BufferedOutput()); + + Assert::same($get->calls[0]->getArgument('software'), [$expected]); + } + public function returnsDLoadExitCode(): void { Assert::same($this->download(new DLoadGetSpy(exitCode: 1)), 1); diff --git a/tests/Unit/Internal/Environment/ProtobufRuntimeTest.php b/tests/Unit/Internal/Environment/ProtobufRuntimeTest.php new file mode 100644 index 0000000..4c233aa --- /dev/null +++ b/tests/Unit/Internal/Environment/ProtobufRuntimeTest.php @@ -0,0 +1,308 @@ +protocConstraint(), $constraint); + } + + /** + * The constraint picks the release by its tag and keeps an installed protoc by its `--version`: + * protoc 21.x reports `libprotoc 3.21.x`, protoc 22 and later `libprotoc 22.0`. + */ + #[DataSet(['3.21.12', '21.12', true], 'protoc 21 release')] + #[DataSet(['3.21.12', '3.21.12', true], 'protoc 21 binary')] + #[DataSet(['3.21.12', '3.2.0', false], 'protoc 3.2')] + #[DataSet(['3.21.12', '22.0', false], 'protoc 22')] + #[DataSet(['3.22.0', '22.5', true], 'protoc 22 release and binary')] + #[DataSet(['3.20.3', '3.20.3', true], 'shared version')] + public function protocConstraintMatchesReleaseAndBinary(string $runtime, string $protoc, bool $satisfies): void + { + $constraint = (new ProtobufRuntime(ProtobufRuntime::PACKAGE, $runtime))->protocConstraint(); + + Assert::same( + Constraint::fromConstraintString((string) $constraint)->isSatisfiedBy(Version::fromVersionString($protoc)), + $satisfies, + ); + } + + public function readsVersionFromProjectInstallation(): void + { + $this->installed('vendor', [['name' => 'google/protobuf', 'version' => 'v5.36.2']]); + + $runtime = ProtobufRuntime::fromPackage($this->dir); + + Assert::same($runtime?->name, 'google/protobuf'); + Assert::same($runtime?->version, 'v5.36.2'); + } + + public function honoursVendorDirOfProject(): void + { + \file_put_contents($this->dir . '/composer.json', '{"config": {"vendor-dir": "lib/vendor"}}'); + $this->installed('vendor', [['name' => 'google/protobuf', 'version' => '4.26.0']]); + $this->installed('lib/vendor', [['name' => 'google/protobuf', 'version' => '4.33.1']]); + + Assert::same(ProtobufRuntime::fromPackage($this->dir)?->version, '4.33.1'); + } + + public function honoursComposerVendorDirVariable(): void + { + $this->installed('deps', [['name' => 'google/protobuf', 'version' => '4.33.1']]); + $_SERVER['COMPOSER_VENDOR_DIR'] = 'deps'; + + try { + $runtime = ProtobufRuntime::fromPackage($this->dir); + } finally { + unset($_SERVER['COMPOSER_VENDOR_DIR']); + } + + Assert::same($runtime?->version, '4.33.1'); + } + + public function honoursComposerVariable(): void + { + \file_put_contents($this->dir . '/app.json', '{"config": {"vendor-dir": "deps"}}'); + $this->installed('deps', [['name' => 'google/protobuf', 'version' => '4.33.1']]); + $_SERVER['COMPOSER'] = 'app.json'; + + Assert::same(ProtobufRuntime::fromPackage($this->dir)?->version, '4.33.1'); + } + + public function expandsUserDirInVendorDir(): void + { + \mkdir($this->dir . '/project'); + \file_put_contents($this->dir . '/project/composer.json', '{"config": {"vendor-dir": "~/deps"}}'); + $this->installed('deps', [['name' => 'google/protobuf', 'version' => '4.33.1']]); + $_SERVER['HOME'] = $this->dir; + + Assert::same(ProtobufRuntime::fromPackage($this->dir . '/project')?->version, '4.33.1'); + } + + public function findsProjectUpFromWorkingDir(): void + { + \file_put_contents($this->dir . '/composer.json', '{}'); + \mkdir($this->dir . '/proto/v1', 0777, true); + + Assert::same(ProtobufRuntime::projectDir($this->dir . '/proto/v1'), $this->dir); + Assert::same(ProtobufRuntime::projectDir($this->dir), $this->dir); + } + + public function findsProjectByComposerVariable(): void + { + \file_put_contents($this->dir . '/composer.json', '{}'); + \mkdir($this->dir . '/app/proto', 0777, true); + \file_put_contents($this->dir . '/app/app.json', '{}'); + $_SERVER['COMPOSER'] = 'app.json'; + + Assert::same(ProtobufRuntime::projectDir($this->dir . '/app/proto'), $this->dir . '/app'); + } + + public function stopsLookingForProjectAtUserHome(): void + { + \file_put_contents($this->dir . '/composer.json', '{}'); + \mkdir($this->dir . '/home/proto', 0777, true); + $_SERVER['HOME'] = $this->dir . '/home'; + + Assert::same(ProtobufRuntime::projectDir($this->dir . '/home/proto'), $this->dir . '/home/proto'); + } + + public function readsComposer1Installation(): void + { + \mkdir($this->dir . '/vendor/composer', 0777, true); + \file_put_contents( + $this->dir . '/vendor/composer/installed.json', + \json_encode([['name' => 'google/protobuf', 'version' => 'v3.20.3']]), + ); + + Assert::same(ProtobufRuntime::fromPackage($this->dir)?->version, 'v3.20.3'); + } + + #[DataSet([[['name' => 'roadrunner/worker', 'version' => '3.8.0']]], 'other packages')] + #[DataSet([[['name' => 'acme/protobuf', 'version' => '1.0.0', 'replace' => ['google/protobuf' => '*']]]], 'replaced package')] + public function findsNothingInProjectWithoutPackage(array $packages): void + { + $this->installed('vendor', $packages); + + Assert::null(ProtobufRuntime::fromPackage($this->dir)); + } + + public function findsNothingInProjectWithoutVendor(): void + { + Assert::null(ProtobufRuntime::fromPackage($this->dir)); + } + + public function readsVersionFromRunningInstallation(): void + { + InstalledVersions::reload(['versions' => ['google/protobuf' => ['pretty_version' => 'v4.33.1']]]); + + $runtime = ProtobufRuntime::fromInstalledVersions(); + + Assert::same($runtime?->name, 'google/protobuf'); + Assert::same($runtime?->version, 'v4.33.1'); + } + + public function findsNothingInRunningInstallationWithoutPackage(): void + { + InstalledVersions::reload(['versions' => ['roadrunner/worker' => ['pretty_version' => 'v3.8.0']]]); + + Assert::null(ProtobufRuntime::fromInstalledVersions()); + } + + public function prefersProjectOverRunningInstallation(): void + { + $this->installed('vendor', [['name' => 'google/protobuf', 'version' => '5.36.2']]); + InstalledVersions::reload(['versions' => ['google/protobuf' => ['pretty_version' => '4.26.0']]]); + + Assert::same((ProtobufRuntime::detect($this->dir)[0] ?? null)?->version, \phpversion('protobuf') ?: '5.36.2'); + } + + public function fallsBackToRunningInstallation(): void + { + InstalledVersions::reload(['versions' => ['google/protobuf' => ['pretty_version' => '4.26.0']]]); + + Assert::same((ProtobufRuntime::detect($this->dir)[0] ?? null)?->version, \phpversion('protobuf') ?: '4.26.0'); + Assert::same(ProtobufRuntime::fromPackage($this->dir)?->version, '4.26.0'); + } + + /** + * The package next to this tool is not the runtime of a project that is not installed yet. + */ + #[DataSet(['composer.json'], 'composer.json')] + #[DataSet(['app.json'], 'COMPOSER variable')] + public function ignoresRunningInstallationWhenProjectIsNotInstalled(string $manifest): void + { + \file_put_contents($this->dir . '/' . $manifest, '{}'); + $manifest === 'composer.json' or $_SERVER['COMPOSER'] = $manifest; + InstalledVersions::reload(['versions' => ['google/protobuf' => ['pretty_version' => '4.26.0']]]); + + Assert::null(ProtobufRuntime::fromPackage($this->dir)); + } + + public function ignoresRunningInstallationWhenAbsoluteManifestIsNotInstalled(): void + { + \mkdir($this->dir . '/project'); + \file_put_contents($this->dir . '/app.json', '{}'); + $_SERVER['COMPOSER'] = $this->dir . '/app.json'; + InstalledVersions::reload(['versions' => ['google/protobuf' => ['pretty_version' => '4.26.0']]]); + + Assert::null(ProtobufRuntime::fromPackage($this->dir . '/project')); + } + + /** + * The package next to this tool is not the project's runtime. + */ + public function ignoresRunningInstallationWhenProjectLacksPackage(): void + { + \extension_loaded('protobuf') and throw new SkipTest('ext-protobuf is loaded'); + $this->installed('vendor', [['name' => 'roadrunner/worker', 'version' => '3.8.0']]); + InstalledVersions::reload(['versions' => ['google/protobuf' => ['pretty_version' => '4.26.0']]]); + + Assert::same(ProtobufRuntime::detect($this->dir), []); + } + + /** + * A loaded extension defines the protobuf classes itself, so the package is never used. + */ + public function prefersExtensionOverPackage(): void + { + $this->installed('vendor', [['name' => 'google/protobuf', 'version' => '5.36.2']]); + + $runtime = ProtobufRuntime::detect($this->dir)[0] ?? null; + + Assert::same($runtime?->name, \extension_loaded('protobuf') ? 'ext-protobuf' : 'google/protobuf'); + } + + public function detectsBothRuntimes(): void + { + $this->installed('vendor', [['name' => 'google/protobuf', 'version' => '5.36.2']]); + + $runtimes = ProtobufRuntime::detect($this->dir); + + Assert::same( + \array_map(static fn(ProtobufRuntime $runtime): string => $runtime->name, $runtimes), + \extension_loaded('protobuf') ? ['ext-protobuf', 'google/protobuf'] : ['google/protobuf'], + ); + Assert::same(\end($runtimes)->version, '5.36.2'); + } + + public function readsExtensionVersion(): void + { + $version = \phpversion('protobuf'); + + $runtime = ProtobufRuntime::fromExtension(); + + Assert::same($runtime?->version, $version === false ? null : $version); + } + + #[BeforeTest] + protected function isolate(): void + { + $this->dir = TempDirectory::create(); + $this->server = $_SERVER; + unset($_SERVER['COMPOSER'], $_ENV['COMPOSER']); + $ref = new \ReflectionClass(InstalledVersions::class); + $this->canGetVendors = $ref->getStaticPropertyValue('canGetVendors'); + $ref->setStaticPropertyValue('canGetVendors', false); + InstalledVersions::reload(['versions' => []]); + } + + #[AfterTest] + protected function restore(): void + { + $_SERVER = $this->server; + TempDirectory::remove($this->dir); + InstalledVersions::reload(null); + (new \ReflectionClass(InstalledVersions::class))->setStaticPropertyValue('canGetVendors', $this->canGetVendors); + } + + private function installed(string $vendor, array $packages): void + { + \mkdir($this->dir . '/' . $vendor . '/composer', 0777, true); + \file_put_contents( + $this->dir . '/' . $vendor . '/composer/installed.json', + \json_encode(['packages' => $packages, 'dev' => true, 'dev-package-names' => []]), + ); + } +} diff --git a/tests/Unit/Stub/DLoadGetSpy.php b/tests/Unit/Stub/DLoadGetSpy.php index f4500f1..1891e80 100644 --- a/tests/Unit/Stub/DLoadGetSpy.php +++ b/tests/Unit/Stub/DLoadGetSpy.php @@ -21,9 +21,14 @@ final class DLoadGetSpy extends Command /** @var list Contents of the `--config` file at the time of each call. */ public array $configs = []; + /** + * @param string|null $binary File name of the fake binary; the requested software name by default. + * @param list $failing Software names that fail with exit code 1 whatever `$exitCode` is. + */ public function __construct( private readonly int $exitCode = self::SUCCESS, - private readonly string $binary = 'rr', + private readonly ?string $binary = null, + private readonly array $failing = [], ) { parent::__construct('get'); } @@ -38,12 +43,16 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->calls[] = $input; $this->configs[] = (string) @\file_get_contents((string) $input->getOption('config')); - if ($this->exitCode === self::SUCCESS) { - \file_put_contents($input->getOption('path') . '/' . $this->binary, 'new binary'); + $software = \implode(' ', $input->getArgument('software')); + $name = \explode(':', $software, 2)[0]; + $exitCode = \in_array($name, $this->failing, true) ? self::FAILURE : $this->exitCode; + + if ($exitCode === self::SUCCESS) { + \file_put_contents($input->getOption('path') . '/' . ($this->binary ?? $name), 'new binary'); } - $output->writeln('dload: ' . \implode(' ', $input->getArgument('software'))); + $output->writeln('dload: ' . $software); - return $this->exitCode; + return $exitCode; } }