From cd6d00ba4f3ffba5506c4cd485012ff8eb0ae67d Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Sat, 10 Oct 2026 00:21:02 +0400 Subject: [PATCH] test: cover release selection, archives, config generation and commands Network access is replaced by Symfony's MockHttpClient and in-memory repositories; archives are built on the fly with PharData. Four tests that expose bugs are skipped with the reason instead of fixing src here. Assisted-By: Claude Opus 5.5 --- tests/Unit/Archive/FactoryTest.php | 140 ++++++++++++ tests/Unit/Archive/PharAwareArchiveTest.php | 94 ++++++++ tests/Unit/Command/EnvironmentOptionsTest.php | 97 +++++++++ .../InstallationLocationOptionTest.php | 74 +++++++ .../Unit/Command/VersionFilterOptionTest.php | 69 ++++++ tests/Unit/CommandTest.php | 30 +++ tests/Unit/Configuration/GeneratorTest.php | 50 +++++ tests/Unit/Configuration/PluginsTest.php | 93 ++++++++ .../Unit/DownloadProtocBinaryCommandTest.php | 33 +++ tests/Unit/Environment/ArchitectureTest.php | 48 +++++ tests/Unit/Environment/EnumTest.php | 29 +++ tests/Unit/Environment/EnvironmentTest.php | 56 +++++ .../Unit/Environment/OperatingSystemTest.php | 52 +++++ tests/Unit/GetBinaryCommandTest.php | 202 ++++++++++++++++++ tests/Unit/MakeConfigCommandTest.php | 89 ++++++++ .../Unit/Repository/AssetsCollectionTest.php | 83 +++++++ tests/Unit/Repository/CollectionTest.php | 133 ++++++++++++ .../Repository/GitHub/GitHubAssetTest.php | 69 ++++++ .../Repository/GitHub/GitHubReleaseTest.php | 122 +++++++++++ .../GitHub/GitHubRepositoryTest.php | 62 ++++++ .../Repository/ReleasesCollectionTest.php | 111 ++++++++++ .../Repository/RepositoriesCollectionTest.php | 41 ++++ .../Version1/StaticRepositoryTest.php | 42 ++++ tests/Unit/Stub/InMemoryRepository.php | 31 +++ tests/Unit/Stub/OptionHost.php | 47 ++++ tests/Unit/Stub/Releases.php | 62 ++++++ tests/Unit/Stub/TempDirectory.php | 70 ++++++ tests/Unit/VersionsCommandTest.php | 123 +++++++++++ 28 files changed, 2152 insertions(+) create mode 100644 tests/Unit/Archive/FactoryTest.php create mode 100644 tests/Unit/Archive/PharAwareArchiveTest.php create mode 100644 tests/Unit/Command/EnvironmentOptionsTest.php create mode 100644 tests/Unit/Command/InstallationLocationOptionTest.php create mode 100644 tests/Unit/Command/VersionFilterOptionTest.php create mode 100644 tests/Unit/CommandTest.php create mode 100644 tests/Unit/Configuration/GeneratorTest.php create mode 100644 tests/Unit/Configuration/PluginsTest.php create mode 100644 tests/Unit/DownloadProtocBinaryCommandTest.php create mode 100644 tests/Unit/Environment/ArchitectureTest.php create mode 100644 tests/Unit/Environment/EnumTest.php create mode 100644 tests/Unit/Environment/EnvironmentTest.php create mode 100644 tests/Unit/Environment/OperatingSystemTest.php create mode 100644 tests/Unit/GetBinaryCommandTest.php create mode 100644 tests/Unit/MakeConfigCommandTest.php create mode 100644 tests/Unit/Repository/AssetsCollectionTest.php create mode 100644 tests/Unit/Repository/CollectionTest.php create mode 100644 tests/Unit/Repository/GitHub/GitHubAssetTest.php create mode 100644 tests/Unit/Repository/GitHub/GitHubReleaseTest.php create mode 100644 tests/Unit/Repository/GitHub/GitHubRepositoryTest.php create mode 100644 tests/Unit/Repository/ReleasesCollectionTest.php create mode 100644 tests/Unit/Repository/RepositoriesCollectionTest.php create mode 100644 tests/Unit/Repository/Version1/StaticRepositoryTest.php create mode 100644 tests/Unit/Stub/InMemoryRepository.php create mode 100644 tests/Unit/Stub/OptionHost.php create mode 100644 tests/Unit/Stub/Releases.php create mode 100644 tests/Unit/Stub/TempDirectory.php create mode 100644 tests/Unit/VersionsCommandTest.php diff --git a/tests/Unit/Archive/FactoryTest.php b/tests/Unit/Archive/FactoryTest.php new file mode 100644 index 0000000..3de80f3 --- /dev/null +++ b/tests/Unit/Archive/FactoryTest.php @@ -0,0 +1,140 @@ +dir = TempDirectory::create(); + } + + #[AfterTest] + public function removeDirectory(): void + { + TempDirectory::remove($this->dir); + } + + #[DataSet(['rr.zip', 'rr.zip', ZipPharArchive::class])] + #[DataSet(['rr.zip', 'RR.ZIP', ZipPharArchive::class], 'upper-case extension')] + #[DataSet(['rr.tar.gz', 'rr.tar.gz', TarPharArchive::class])] + public function createPicksArchiveByExtension(string $source, string $name, string $class): void + { + $path = TempDirectory::archive($this->dir, $source, ['rr' => 'binary']); + \rename($path, $this->dir . '/' . $name); + + $archive = (new Factory())->create(new \SplFileInfo($this->dir . '/' . $name)); + + Assert::instanceOf($archive, $class); + } + + public function createRejectsUnknownExtension(): never + { + \file_put_contents($this->dir . '/rr.rar', 'data'); + + Expect::exception(\InvalidArgumentException::class) + ->withMessageContaining('Can not open the archive "rr.rar"'); + + (new Factory())->create(new \SplFileInfo($this->dir . '/rr.rar')); + } + + public function createReportsErrorsOfMatchingFormats(): never + { + Expect::exception(\InvalidArgumentException::class) + ->withMessageContaining('Archive "missing.zip" is not a file'); + + (new Factory())->create(new \SplFileInfo($this->dir . '/missing.zip')); + } + + public function extendedMatcherTakesPriority(): void + { + $path = TempDirectory::archive($this->dir, 'rr.zip', ['rr' => 'binary']); + $custom = new class implements ArchiveInterface { + public function extract(iterable $mappings): \Generator + { + yield from []; + } + }; + + $archive = (new Factory()) + ->extend(static fn(\SplFileInfo $file): ArchiveInterface => $custom) + ->create(new \SplFileInfo($path)); + + Assert::same($archive, $custom); + } + + public function extendedMatcherMayDecline(): void + { + $path = TempDirectory::archive($this->dir, 'rr.zip', ['rr' => 'binary']); + + $archive = (new Factory()) + ->extend(static fn(\SplFileInfo $file): ?ArchiveInterface => null) + ->create(new \SplFileInfo($path)); + + Assert::instanceOf($archive, ZipPharArchive::class); + } + + public function fromAssetDownloadsIntoTempDirectory(): void + { + $source = TempDirectory::archive($this->dir, 'source.zip', ['rr' => 'binary']); + $temp = $this->dir . '/temp'; + \mkdir($temp); + $asset = new GitHubAsset( + new MockHttpClient(new MockResponse((string) \file_get_contents($source))), + 'roadrunner-2024.1.0-linux-amd64.zip', + 'https://example.com/rr.zip', + ); + + $archive = (new Factory())->fromAsset($asset, null, $temp); + + Assert::instanceOf($archive, ZipPharArchive::class); + Assert::same(\file_get_contents($temp . '/roadrunner-2024.1.0-linux-amd64.zip'), \file_get_contents($source)); + } + + public function fromAssetRejectsMissingTempDirectory(): never + { + Expect::exception(\LogicException::class) + ->withMessageContaining('is not writeable'); + + (new Factory())->fromAsset( + new GitHubAsset(new MockHttpClient(), 'rr.zip', 'https://example.com/rr.zip'), + null, + $this->dir . '/missing', + ); + } + + public function fromAssetRethrowsDownloadErrors(): never + { + $asset = new GitHubAsset( + new MockHttpClient(new MockResponse('', ['error' => 'connection refused'])), + 'rr.zip', + 'https://example.com/rr.zip', + ); + + Expect::exception(\RuntimeException::class) + ->withMessageContaining('connection refused'); + + (new Factory())->fromAsset($asset, null, $this->dir); + } +} diff --git a/tests/Unit/Archive/PharAwareArchiveTest.php b/tests/Unit/Archive/PharAwareArchiveTest.php new file mode 100644 index 0000000..5fedf0f --- /dev/null +++ b/tests/Unit/Archive/PharAwareArchiveTest.php @@ -0,0 +1,94 @@ +dir = TempDirectory::create(); + } + + #[AfterTest] + public function removeDirectory(): void + { + TempDirectory::remove($this->dir); + } + + #[DataSet(['rr.zip', ZipPharArchive::class])] + #[DataSet(['rr.tar.gz', TarPharArchive::class])] + #[DataSet(['rr.tar', PharArchive::class])] + public function extractCopiesMappedFiles(string $name, string $class): void + { + $path = TempDirectory::archive($this->dir, $name, [ + 'roadrunner-2024.1.0/rr' => 'binary', + 'roadrunner-2024.1.0/README.md' => 'readme', + ]); + $archive = new $class(new \SplFileInfo($path)); + + $extracted = []; + foreach ($archive->extract(['rr' => $this->dir . '/rr', 'rr.exe' => $this->dir . '/rr.exe']) as $from => $to) { + $extracted[$from->getFilename()] = $to->getPathname(); + } + + Assert::same($extracted, ['rr' => $this->dir . '/rr']); + Assert::same(\file_get_contents($this->dir . '/rr'), 'binary'); + Assert::false(\is_file($this->dir . '/README.md')); + } + + public function extractSkipsFileWhenConsumerDeclines(): void + { + $path = TempDirectory::archive($this->dir, 'rr.zip', ['roadrunner/rr' => 'binary']); + $extractor = (new ZipPharArchive(new \SplFileInfo($path)))->extract(['rr' => $this->dir . '/rr']); + + Assert::true($extractor->valid()); + $extractor->send(false); + + Assert::false($extractor->valid()); + Assert::false(\is_file($this->dir . '/rr')); + } + + #[Skip('Bug: extract() reopens $this->archive, whose pathname points at the first archive entry rather than the archive itself, so a binary in the archive root cannot be extracted')] + public function extractFindsBinaryInArchiveRoot(): void + { + $path = TempDirectory::archive($this->dir, 'rr.zip', ['LICENSE' => 'license', 'rr' => 'binary']); + + \iterator_to_array((new ZipPharArchive(new \SplFileInfo($path)))->extract(['rr' => $this->dir . '/rr'])); + + Assert::same(\file_get_contents($this->dir . '/rr'), 'binary'); + } + + public function rejectsMissingFile(): never + { + Expect::exception(\InvalidArgumentException::class) + ->withMessage('Archive "missing.zip" is not a file'); + + new ZipPharArchive(new \SplFileInfo($this->dir . '/missing.zip')); + } + + public function rejectsDirectory(): never + { + Expect::exception(\InvalidArgumentException::class) + ->withMessageContaining('is not a file'); + + new ZipPharArchive(new \SplFileInfo($this->dir)); + } +} diff --git a/tests/Unit/Command/EnvironmentOptionsTest.php b/tests/Unit/Command/EnvironmentOptionsTest.php new file mode 100644 index 0000000..ee4d51b --- /dev/null +++ b/tests/Unit/Command/EnvironmentOptionsTest.php @@ -0,0 +1,97 @@ +command); + $input = $host->input(); + + Assert::same($option->getName(), 'stability'); + Assert::same($host->command->getDefinition()->getOption('stability')->getShortcut(), 's'); + Assert::same($option->get($input, $host->io($input)), 'stable'); + Assert::same($host->display(), ''); + } + + public function stabilityAcceptsKnownValue(): void + { + $host = new OptionHost(); + $option = new StabilityOption($host->command); + $input = $host->input(['--stability' => 'beta']); + + Assert::same($option->get($input, $host->io($input)), 'beta'); + Assert::same($host->display(), ''); + } + + public function stabilityWarnsAboutUnknownValue(): void + { + $host = new OptionHost(); + $option = new StabilityOption($host->command); + $input = $host->input(['--stability' => 'nightly']); + + Assert::same($option->get($input, $host->io($input)), 'nightly'); + Assert::string($host->display()) + ->ignoringWhitespace(lineBreaks: true) + ->contains('Possibly invalid stability (--stability=nightly) option (available: stable, RC, beta, alpha, dev)'); + } + + public function operatingSystemWarnsAboutUnknownValue(): void + { + $host = new OptionHost(); + $option = new OperatingSystemOption($host->command); + $input = $host->input(['--os' => 'macos']); + + Assert::same($option->get($input, $host->io($input)), 'macos'); + Assert::string($host->display()) + ->ignoringWhitespace(lineBreaks: true) + ->contains('Possibly invalid operating system (--os=macos) option'); + } + + public function operatingSystemAcceptsKnownValue(): void + { + $host = new OptionHost(); + $option = new OperatingSystemOption($host->command, 'system', 'y'); + $input = $host->input(['--system' => 'darwin']); + + Assert::same($option->getName(), 'system'); + Assert::same($option->get($input, $host->io($input)), 'darwin'); + Assert::same($host->display(), ''); + } + + public function architectureWarnsAboutUnknownValue(): void + { + $host = new OptionHost(); + $option = new ArchitectureOption($host->command); + $input = $host->input(['--arch' => 'x86_64']); + + Assert::same($option->get($input, $host->io($input)), 'x86_64'); + Assert::string($host->display()) + ->ignoringWhitespace(lineBreaks: true) + ->contains('Possibly invalid architecture (--arch=x86_64) option (available: amd64, arm64)'); + } + + public function architectureAcceptsShortcut(): void + { + $host = new OptionHost(); + $option = new ArchitectureOption($host->command); + $input = $host->input(['-a' => 'arm64']); + + Assert::same($option->get($input, $host->io($input)), 'arm64'); + } +} diff --git a/tests/Unit/Command/InstallationLocationOptionTest.php b/tests/Unit/Command/InstallationLocationOptionTest.php new file mode 100644 index 0000000..d2f86fa --- /dev/null +++ b/tests/Unit/Command/InstallationLocationOptionTest.php @@ -0,0 +1,74 @@ +dir = TempDirectory::create(); + } + + #[AfterTest] + public function removeDirectory(): void + { + TempDirectory::remove($this->dir); + } + + public function defaultsToWorkingDirectory(): void + { + $host = new OptionHost(); + $option = new InstallationLocationOption($host->command); + $input = $host->input(); + + Assert::same($option->get($input, $host->io($input)), \getcwd()); + } + + public function acceptsWritableDirectory(): void + { + $host = new OptionHost(); + $option = new InstallationLocationOption($host->command); + $input = $host->input(['--location' => $this->dir]); + + Assert::same($option->get($input, $host->io($input)), $this->dir); + } + + public function rejectsMissingDirectory(): never + { + $host = new OptionHost(); + $option = new InstallationLocationOption($host->command); + $input = $host->input(['-l' => $this->dir . '/missing']); + + Expect::exception(\InvalidArgumentException::class) + ->withMessage('Installation directory not found or not writable'); + + $option->get($input, $host->io($input)); + } + + public function rejectsFile(): never + { + \file_put_contents($this->dir . '/file', ''); + $host = new OptionHost(); + $option = new InstallationLocationOption($host->command); + $input = $host->input(['--location' => $this->dir . '/file']); + + Expect::exception(\InvalidArgumentException::class); + + $option->get($input, $host->io($input)); + } +} diff --git a/tests/Unit/Command/VersionFilterOptionTest.php b/tests/Unit/Command/VersionFilterOptionTest.php new file mode 100644 index 0000000..df174ab --- /dev/null +++ b/tests/Unit/Command/VersionFilterOptionTest.php @@ -0,0 +1,69 @@ +command); + $input = $host->input(); + + Assert::same($option->get($input, $host->io($input)), Version::constraint()); + } + + public function findReturnsMatchingReleasesNewestFirst(): void + { + $host = new OptionHost(); + $option = new VersionFilterOption($host->command); + $input = $host->input(['--filter' => '^2024.1']); + $repository = new InMemoryRepository( + Releases::release('v2024.1.0'), + Releases::release('v2023.3.12'), + Releases::release('v2024.2.1'), + ); + + $releases = $option->find($input, $host->io($input), $repository); + + Assert::same(Releases::versions($releases), ['v2024.2.1', 'v2024.1.0']); + } + + public function findFailsWhenNothingMatches(): never + { + $host = new OptionHost(); + $option = new VersionFilterOption($host->command); + $input = $host->input(['-f' => '^2025.1']); + $repository = new InMemoryRepository(Releases::release('v2023.3.12'), Releases::release('v2024.1.0')); + + Expect::exception(\UnexpectedValueException::class) + ->withMessage( + "Could not find any available RoadRunner binary version which meets version criterion (--filter=^2025.1)\n" + . 'Available: v2024.1.0, v2023.3.12', + ); + + $option->find($input, $host->io($input), $repository); + } + + public function choicesListsUniqueVersions(): void + { + $option = new VersionFilterOption((new OptionHost())->command); + + Assert::same( + $option->choices(Releases::collection('v2024.1.0', 'v2023.3.12', 'v2024.1.0')), + 'v2024.1.0, v2023.3.12', + ); + } +} diff --git a/tests/Unit/CommandTest.php b/tests/Unit/CommandTest.php new file mode 100644 index 0000000..1453bb7 --- /dev/null +++ b/tests/Unit/CommandTest.php @@ -0,0 +1,30 @@ +getRepository(); + } + }; + + $repository = $command->repository(); + + Assert::instanceOf($repository, RepositoriesCollection::class); + Assert::same($repository->getName(), 'unknown/unknown'); + } +} diff --git a/tests/Unit/Configuration/GeneratorTest.php b/tests/Unit/Configuration/GeneratorTest.php new file mode 100644 index 0000000..c244d69 --- /dev/null +++ b/tests/Unit/Configuration/GeneratorTest.php @@ -0,0 +1,50 @@ +generate(Plugins::fromPlugins([]))); + + Assert::same(\array_keys($config), ['version', 'rpc', 'server', 'http', 'jobs', 'kv', 'metrics']); + Assert::same($config['version'], '3'); + } + + #[DataSet(['http'])] + #[DataSet(['jobs'])] + #[DataSet(['grpc'])] + #[DataSet(['tcp'])] + public function addsServerForWorkerPlugins(string $plugin): void + { + $config = Yaml::parse((new Generator())->generate(Plugins::fromPlugins([$plugin]))); + + Assert::same(\array_keys($config), ['version', 'rpc', $plugin, 'server']); + } + + public function rendersEachSectionOnce(): void + { + $config = Yaml::parse((new Generator())->generate(Plugins::fromPlugins(['http', 'jobs', 'server']))); + + Assert::array(\array_keys($config))->sameElementsAs(['version', 'rpc', 'http', 'jobs', 'server']); + } + + public function rendersNestedSectionContent(): void + { + $config = Yaml::parse((new Generator())->generate(Plugins::fromPlugins(['http']))); + + Assert::same($config['http']['address'], '0.0.0.0:8080'); + Assert::same($config['rpc'], ['listen' => 'tcp://127.0.0.1:6001']); + } +} diff --git a/tests/Unit/Configuration/PluginsTest.php b/tests/Unit/Configuration/PluginsTest.php new file mode 100644 index 0000000..fc36fef --- /dev/null +++ b/tests/Unit/Configuration/PluginsTest.php @@ -0,0 +1,93 @@ +}> + */ + public static function sections(): iterable + { + foreach (\glob(\dirname(__DIR__, 3) . '/src/Configuration/Section/*.php') as $file) { + $class = 'Spiral\\RoadRunner\\Console\\Configuration\\Section\\' . \basename($file, '.php'); + $reflection = new \ReflectionClass($class); + if ($reflection->isInstantiable()) { + yield $reflection->getShortName() => [$class]; + } + } + } + + public function usesDefaultPluginsWhenNothingRequested(): void + { + Assert::same(Plugins::fromPlugins([])->getPlugins(), self::DEFAULTS); + } + + public function selectsRequestedPluginsByShortName(): void + { + Assert::array(Plugins::fromPlugins(['kv', 'http'])->getPlugins()) + ->sameElementsAs([Http::class, Kv::class]); + } + + public function ignoresUnknownPlugins(): void + { + Assert::same(Plugins::fromPlugins(['unknown'])->getPlugins(), []); + } + + public function webPresetSelectsHttpAndJobs(): void + { + Assert::array(Plugins::fromPreset('web')->getPlugins()) + ->sameElementsAs([Http::class, Jobs::class]); + } + + public function unknownPresetFallsBackToDefaults(): void + { + Assert::same(Plugins::fromPreset('unknown')->getPlugins(), self::DEFAULTS); + } + + /** + * @param class-string $class + */ + #[DataProvider('sections')] + public function everySectionIsAvailableByShortName(string $class): void + { + Assert::same(Plugins::fromPlugins([$class::getShortName()])->getPlugins(), [$class]); + } + + /** + * @param class-string $class + */ + #[DataProvider('sections')] + public function everySectionRendersUnderItsShortName(string $class): void + { + $content = (new $class())->render(); + + Assert::same(\array_keys($content), [$class::getShortName()]); + } +} diff --git a/tests/Unit/DownloadProtocBinaryCommandTest.php b/tests/Unit/DownloadProtocBinaryCommandTest.php new file mode 100644 index 0000000..c89f13a --- /dev/null +++ b/tests/Unit/DownloadProtocBinaryCommandTest.php @@ -0,0 +1,33 @@ +getName(), 'download-protoc-binary'); + Assert::same($command->getDescription(), 'Install or update protoc-gen-php-grpc binary'); + } + + public function definesEnvironmentOptions(): void + { + $definition = (new DownloadProtocBinaryCommand())->getDefinition(); + + Assert::array(\array_keys($definition->getOptions())) + ->sameElementsAs(['os', 'arch', 'filter', 'location', 'stability']); + Assert::same($definition->getOption('stability')->getDefault(), 'stable'); + } +} diff --git a/tests/Unit/Environment/ArchitectureTest.php b/tests/Unit/Environment/ArchitectureTest.php new file mode 100644 index 0000000..a9d3efd --- /dev/null +++ b/tests/Unit/Environment/ArchitectureTest.php @@ -0,0 +1,48 @@ + 'amd64', + 'ARCH_ARM_64' => 'arm64', + ]); + } + + #[DataSet(['amd64', true])] + #[DataSet(['arm64', true])] + #[DataSet(['x86_64', false], 'uname alias is not a release architecture')] + #[DataSet(['AMD64', false], 'case-sensitive')] + public function isValid(string $value, bool $expected): void + { + Assert::same(Architecture::isValid($value), $expected); + } + + public function detectsCurrentArchitecture(): void + { + $uname = \php_uname('m'); + $expected = match (true) { + \in_array($uname, ['AMD64', 'amd64', 'x86', 'x64', 'x86_64'], true) => Architecture::ARCH_X86_64, + \in_array($uname, ['arm64', 'aarch64'], true) => Architecture::ARCH_ARM_64, + default => null, + }; + + if ($expected === null) { + Expect::exception(\OutOfRangeException::class); + } + + Assert::same(Architecture::createFromGlobals(), $expected); + } +} diff --git a/tests/Unit/Environment/EnumTest.php b/tests/Unit/Environment/EnumTest.php new file mode 100644 index 0000000..0933935 --- /dev/null +++ b/tests/Unit/Environment/EnumTest.php @@ -0,0 +1,29 @@ + 'RC']); + } + + public function returnsEmptyListWhenNothingMatches(): void + { + Assert::same(Enum::values(Stability::class, 'ARCH_'), []); + } + + public function returnsEmptyListForUnknownClass(): void + { + Assert::same(Enum::values('Spiral\RoadRunner\Console\NonExistent', 'X'), []); + } +} diff --git a/tests/Unit/Environment/EnvironmentTest.php b/tests/Unit/Environment/EnvironmentTest.php new file mode 100644 index 0000000..b7c8987 --- /dev/null +++ b/tests/Unit/Environment/EnvironmentTest.php @@ -0,0 +1,56 @@ + 'explicit']), 'explicit'); + } + + public function envWinsOverServer(): void + { + $_ENV[self::KEY] = 'env'; + $_SERVER[self::KEY] = 'server'; + + Assert::same(Environment::get(self::KEY), 'env'); + } + + public function fallsBackToServer(): void + { + $_SERVER[self::KEY] = 'server'; + + Assert::same(Environment::get(self::KEY), 'server'); + } + + public function returnsDefaultWhenMissing(): void + { + Assert::same(Environment::get(self::KEY, 'default'), 'default'); + Assert::null(Environment::get(self::KEY)); + } + + public function ignoresNonStringValues(): void + { + Assert::same(Environment::get(self::KEY, 'default', [self::KEY => ['array']]), 'default'); + } +} diff --git a/tests/Unit/Environment/OperatingSystemTest.php b/tests/Unit/Environment/OperatingSystemTest.php new file mode 100644 index 0000000..6291c83 --- /dev/null +++ b/tests/Unit/Environment/OperatingSystemTest.php @@ -0,0 +1,52 @@ + 'darwin', + 'OS_BSD' => 'freebsd', + 'OS_LINUX' => 'linux', + 'OS_WINDOWS' => 'windows', + 'OS_ALPINE' => 'unknown-musl', + ]); + } + + #[DataSet(['linux', true])] + #[DataSet(['unknown-musl', true])] + #[DataSet(['Linux', false], 'case-sensitive')] + #[DataSet(['macos', false])] + public function isValid(string $value, bool $expected): void + { + Assert::same(OperatingSystem::isValid($value), $expected); + } + + public function detectsCurrentOperatingSystem(): void + { + $expected = match (\PHP_OS_FAMILY) { + 'Windows' => OperatingSystem::OS_WINDOWS, + 'BSD' => OperatingSystem::OS_BSD, + 'Darwin' => OperatingSystem::OS_DARWIN, + 'Linux' => OperatingSystem::OS_LINUX, + default => null, + }; + + if ($expected === null) { + Expect::exception(\OutOfRangeException::class); + } + + Assert::same(OperatingSystem::createFromGlobals(), $expected); + } +} diff --git a/tests/Unit/GetBinaryCommandTest.php b/tests/Unit/GetBinaryCommandTest.php new file mode 100644 index 0000000..02fae4a --- /dev/null +++ b/tests/Unit/GetBinaryCommandTest.php @@ -0,0 +1,202 @@ + */ + private array $downloads = []; + + #[BeforeTest] + public function createDirectory(): void + { + $this->dir = TempDirectory::create(); + $this->target = $this->dir . '/bin'; + \mkdir($this->target); + } + + #[AfterTest] + public function removeDirectory(): void + { + TempDirectory::remove($this->dir); + } + + public function describesItself(): void + { + $command = new GetBinaryCommand(); + + Assert::same($command->getName(), 'get-binary'); + Assert::same($command->getDescription(), 'Install or update RoadRunner binary'); + } + + public function installsBinaryOfNewestMatchingRelease(): void + { + $tester = $this->tester( + $this->release('v2024.1.0', ['roadrunner-2024.1.0-linux-amd64.zip']), + $this->release('v2024.2.0', [ + 'roadrunner-2024.2.0-linux-amd64.deb', + 'roadrunner-2024.2.0-linux-amd64.zip', + 'roadrunner-2024.2.0-darwin-amd64.zip', + 'roadrunner-2024.2.0-linux-arm64.zip', + ]), + $this->release('v2024.3.0-beta.1', ['roadrunner-2024.3.0-beta.1-linux-amd64.zip']), + $this->release('v2025.1.0', ['roadrunner-2025.1.0-linux-amd64.zip']), + ); + + $status = $tester->execute($this->input(['--no-config' => true]), ['interactive' => false]); + + Assert::same($status, 0); + Assert::same($this->downloads, ['https://example.com/download/roadrunner-2024.2.0-linux-amd64.zip']); + Assert::same(\file_get_contents($this->target . '/rr'), 'binary v2024.2.0'); + Assert::false(\is_file($this->target . '/.rr.yaml')); + Assert::string($tester->getDisplay()) + ->contains('roadrunner-server/roadrunner (v2024.2.0): Downloading...') + ->contains('RoadRunner (v2024.2.0) has been installed into'); + } + + public function skipsReleasesWithoutSuitableAssembly(): void + { + $tester = $this->tester( + $this->release('v2024.1.0', ['roadrunner-2024.1.0-linux-amd64.zip']), + $this->release('v2024.2.0', ['roadrunner-2024.2.0-darwin-amd64.zip']), + ); + + $status = $tester->execute($this->input(['--no-config' => true]), ['interactive' => false]); + + Assert::same($status, 0); + Assert::same(\file_get_contents($this->target . '/rr'), 'binary v2024.1.0'); + Assert::string($tester->getDisplay()) + ->ignoringWhitespace(lineBreaks: true) + ->contains('roadrunner-server/roadrunner v2024.2.0 does not contain available assembly'); + } + + public function failsWhenNoReleaseHasSuitableAssembly(): never + { + $tester = $this->tester($this->release('v2024.1.0', ['roadrunner-2024.1.0-darwin-amd64.zip'])); + + Expect::exception(\UnexpectedValueException::class) + ->withMessageContaining('(--os=linux --arch=amd64 --stability=stable). Available: v2024.1.0'); + + $tester->execute($this->input(['--no-config' => true]), ['interactive' => false]); + } + + public function generatesConfigurationForPreset(): void + { + $tester = $this->tester($this->release('v2024.1.0', ['roadrunner-2024.1.0-linux-amd64.zip'])); + + $status = $tester->execute($this->input(['--preset' => 'web']), ['interactive' => false]); + + Assert::same($status, 0); + Assert::array(\array_keys(Yaml::parseFile($this->target . '/.rr.yaml'))) + ->sameElementsAs(['version', 'rpc', 'http', 'jobs', 'server']); + } + + public function keepsExistingConfiguration(): void + { + \file_put_contents($this->target . '/.rr.yaml', 'existing'); + $tester = $this->tester($this->release('v2024.1.0', ['roadrunner-2024.1.0-linux-amd64.zip'])); + + $status = $tester->execute($this->input(['--plugin' => ['kv']]), ['interactive' => false]); + + Assert::same($status, 0); + Assert::same(\file_get_contents($this->target . '/.rr.yaml'), 'existing'); + } + + public function keepsExistingBinaryUnlessConfirmed(): void + { + \file_put_contents($this->target . '/rr', 'old binary'); + $tester = $this->tester($this->release('v2024.1.0', ['roadrunner-2024.1.0-linux-amd64.zip'])); + + $tester->execute($this->input(['--no-config' => true]), ['interactive' => false]); + + Assert::same(\file_get_contents($this->target . '/rr'), 'old binary'); + Assert::string($tester->getDisplay()) + ->contains('RoadRunner binary file already exists!') + ->contains('Skipping RoadRunner installation...'); + } + + public function overwritesExistingBinaryWhenConfirmed(): void + { + \file_put_contents($this->target . '/rr', 'old binary'); + $tester = $this->tester($this->release('v2024.1.0', ['roadrunner-2024.1.0-linux-amd64.zip'])); + $tester->setInputs(['yes']); + + $tester->execute($this->input(['--no-config' => true])); + + Assert::same(\file_get_contents($this->target . '/rr'), 'binary v2024.1.0'); + } + + /** + * @param array $input + * @return array + */ + private function input(array $input): array + { + return [ + '--location' => $this->target, + '--os' => 'linux', + '--arch' => 'amd64', + '--filter' => '^2024.1', + ] + $input; + } + + /** + * Creates a release whose assets are zip archives with an "rr" file named after the release. + * + * @param list $assets + */ + private function release(string $tag, array $assets): ReleaseInterface + { + $archive = TempDirectory::archive($this->dir, \bin2hex(\random_bytes(4)) . '.zip', [ + 'roadrunner/rr' => 'binary ' . $tag, + ]); + $content = (string) \file_get_contents($archive); + $client = new MockHttpClient(function (string $method, string $url) use ($content): MockResponse { + $this->downloads[] = $url; + + return new MockResponse($content); + }); + + return Releases::release($tag, $assets, $client); + } + + private function tester(ReleaseInterface ...$releases): CommandTester + { + $command = new class(new InMemoryRepository(...$releases)) extends GetBinaryCommand { + public function __construct( + private readonly RepositoryInterface $repository, + ) { + parent::__construct(); + } + + protected function getRepository(): RepositoryInterface + { + return $this->repository; + } + }; + + return new CommandTester($command); + } +} diff --git a/tests/Unit/MakeConfigCommandTest.php b/tests/Unit/MakeConfigCommandTest.php new file mode 100644 index 0000000..435d991 --- /dev/null +++ b/tests/Unit/MakeConfigCommandTest.php @@ -0,0 +1,89 @@ +dir = TempDirectory::create(); + } + + #[AfterTest] + public function removeDirectory(): void + { + TempDirectory::remove($this->dir); + } + + public function isNamedMakeConfig(): void + { + Assert::same((new MakeConfigCommand())->getName(), 'make-config'); + Assert::same((new MakeConfigCommand('custom'))->getName(), 'custom'); + } + + public function writesDefaultConfiguration(): void + { + $status = $this->run(['--location' => $this->dir]); + + Assert::same($status, Command::SUCCESS); + Assert::same( + \array_keys($this->config()), + ['version', 'rpc', 'server', 'http', 'jobs', 'kv', 'metrics'], + ); + } + + public function writesSelectedPlugins(): void + { + $status = $this->run(['--location' => $this->dir, '--plugin' => ['kv']]); + + Assert::same($status, Command::SUCCESS); + Assert::same(\array_keys($this->config()), ['version', 'rpc', 'kv']); + } + + public function presetTakesPrecedenceOverPlugins(): void + { + $status = $this->run(['--location' => $this->dir, '--plugin' => ['kv'], '--preset' => 'web']); + + Assert::same($status, Command::SUCCESS); + Assert::array(\array_keys($this->config()))->sameElementsAs(['version', 'rpc', 'http', 'jobs', 'server']); + } + + public function keepsExistingConfiguration(): void + { + \file_put_contents($this->dir . '/.rr.yaml', 'existing'); + + $status = $this->run(['--location' => $this->dir]); + + Assert::same($status, Command::FAILURE); + Assert::same(\file_get_contents($this->dir . '/.rr.yaml'), 'existing'); + } + + /** + * @param array $input + */ + private function run(array $input): int + { + return (new CommandTester(new MakeConfigCommand()))->execute($input, ['interactive' => false]); + } + + private function config(): array + { + return Yaml::parseFile($this->dir . '/.rr.yaml'); + } +} diff --git a/tests/Unit/Repository/AssetsCollectionTest.php b/tests/Unit/Repository/AssetsCollectionTest.php new file mode 100644 index 0000000..95a246c --- /dev/null +++ b/tests/Unit/Repository/AssetsCollectionTest.php @@ -0,0 +1,83 @@ +onlyRoadrunner()); + + Assert::count($names, 6); + Assert::array($names)->notContains('protoc-gen-php-grpc-2024.1.0-linux-amd64.tar.gz'); + } + + public function exceptDebPackagesIsCaseInsensitive(): void + { + $assets = (new AssetsCollection([ + Releases::asset('roadrunner-linux-amd64.deb'), + Releases::asset('roadrunner-linux-amd64.DEB'), + Releases::asset('roadrunner-linux-amd64.tar.gz'), + ]))->exceptDebPackages(); + + Assert::same(self::names($assets), ['roadrunner-linux-amd64.tar.gz']); + } + + public function filtersByOperatingSystemAndArchitecture(): void + { + $assets = self::assets() + ->onlyRoadrunner() + ->exceptDebPackages() + ->whereOperatingSystem('linux') + ->whereArchitecture('amd64'); + + Assert::same(self::names($assets), ['roadrunner-2024.1.0-linux-amd64.tar.gz']); + } + + public function filterArgumentsAreCaseInsensitive(): void + { + $assets = self::assets()->whereOperatingSystem('Darwin')->whereArchitecture('ARM64'); + + Assert::same(self::names($assets), ['roadrunner-2024.1.0-darwin-arm64.tar.gz']); + } + + public function filtersMuslBuilds(): void + { + Assert::same( + self::names(self::assets()->whereOperatingSystem('unknown-musl')), + ['roadrunner-2024.1.0-unknown-musl-amd64.zip'], + ); + } + + private static function assets(): AssetsCollection + { + return new AssetsCollection(\array_map(Releases::asset(...), self::ASSETS)); + } + + /** + * @return list + */ + private static function names(AssetsCollection $assets): array + { + return \array_map(static fn(AssetInterface $asset): string => $asset->getName(), $assets->toArray()); + } +} diff --git a/tests/Unit/Repository/CollectionTest.php b/tests/Unit/Repository/CollectionTest.php new file mode 100644 index 0000000..d9b2584 --- /dev/null +++ b/tests/Unit/Repository/CollectionTest.php @@ -0,0 +1,133 @@ +withMessage('Unsupported iterable type string'); + + AssetsCollection::create('assets'); + } + + public function filterAndExceptAreComplementary(): void + { + $collection = self::abc(); + $isB = static fn(AssetInterface $asset): bool => $asset->getName() === 'b'; + + Assert::same(self::names($collection->filter($isB)), ['b']); + Assert::same(self::names($collection->except($isB)), ['a', 'c']); + } + + public function mapTransformsItems(): void + { + $mapped = self::abc()->map( + static fn(AssetInterface $asset): AssetInterface => Releases::asset(\strtoupper($asset->getName())), + ); + + Assert::same(self::names($mapped), ['A', 'B', 'C']); + } + + public function firstReturnsFirstMatchingItem(): void + { + $collection = self::abc(); + $notA = static fn(AssetInterface $asset): bool => $asset->getName() !== 'a'; + + Assert::same($collection->first()?->getName(), 'a'); + Assert::same($collection->first($notA)?->getName(), 'b'); + Assert::null($collection->first(static fn(): bool => false)); + Assert::null((new AssetsCollection([]))->first()); + } + + public function firstOrFallsBackToOtherwise(): void + { + $fallback = Releases::asset('fallback'); + + Assert::same(self::abc()->firstOr(static fn(): AssetInterface => $fallback)->getName(), 'a'); + Assert::same((new AssetsCollection([]))->firstOr(static fn(): AssetInterface => $fallback), $fallback); + } + + public function whenEmptyCallsBackOnlyForEmptyCollection(): void + { + $calls = 0; + $callback = static function () use (&$calls): void { + ++$calls; + }; + $collection = self::abc(); + $empty = new AssetsCollection([]); + + Assert::same($collection->whenEmpty($callback), $collection); + Assert::same($calls, 0); + Assert::same($empty->whenEmpty($callback), $empty); + Assert::same($calls, 1); + } + + public function countEmptyAndToArray(): void + { + $filtered = self::abc()->filter(static fn(AssetInterface $asset): bool => $asset->getName() !== 'a'); + + Assert::count($filtered, 2); + Assert::false($filtered->empty()); + Assert::true((new AssetsCollection([]))->empty()); + Assert::array($filtered->toArray())->isList()->hasCount(2); + } + + private static function abc(): AssetsCollection + { + return new AssetsCollection([Releases::asset('a'), Releases::asset('b'), Releases::asset('c')]); + } + + /** + * @return list + */ + private static function names(AssetsCollection $collection): array + { + return \array_map(static fn(AssetInterface $asset): string => $asset->getName(), $collection->toArray()); + } +} diff --git a/tests/Unit/Repository/GitHub/GitHubAssetTest.php b/tests/Unit/Repository/GitHub/GitHubAssetTest.php new file mode 100644 index 0000000..56588cf --- /dev/null +++ b/tests/Unit/Repository/GitHub/GitHubAssetTest.php @@ -0,0 +1,69 @@ + 'roadrunner-2024.1.0-linux-amd64.tar.gz', + 'browser_download_url' => 'https://example.com/rr.tar.gz', + ]); + + Assert::same($asset->getName(), 'roadrunner-2024.1.0-linux-amd64.tar.gz'); + Assert::same($asset->getUri(), 'https://example.com/rr.tar.gz'); + } + + #[DataSet([['browser_download_url' => 'https://example.com'], '"name"'], 'missing name')] + #[DataSet([['name' => 1, 'browser_download_url' => 'https://example.com'], '"name"'], 'non-string name')] + #[DataSet([['name' => 'rr.zip'], '"browser_download_url"'], 'missing url')] + #[DataSet([['name' => 'rr.zip', 'browser_download_url' => null], '"browser_download_url"'], 'null url')] + public function fromApiResponseValidatesFields(array $data, string $field): never + { + Expect::exception(\InvalidArgumentException::class) + ->withMessageContaining($field); + + GitHubAsset::fromApiResponse(new MockHttpClient(), $data); + } + + public function downloadStreamsBodyFromUri(): void + { + $requested = null; + $client = new MockHttpClient(static function (string $method, string $url) use (&$requested): MockResponse { + $requested = $method . ' ' . $url; + + return new MockResponse(['first-', 'second']); + }); + $asset = new GitHubAsset($client, 'rr.zip', 'https://example.com/rr.zip'); + + $content = \implode('', \iterator_to_array($asset->download(), false)); + + Assert::same($content, 'first-second'); + Assert::same($requested, 'GET https://example.com/rr.zip'); + } + + public function downloadReportsProgress(): void + { + $client = new MockHttpClient(new MockResponse('0123456789', ['response_headers' => ['content-length' => '10']])); + $asset = new GitHubAsset($client, 'rr.zip', 'https://example.com/rr.zip'); + $downloaded = 0; + + \iterator_to_array($asset->download(static function (int $size) use (&$downloaded): void { + $downloaded = $size; + }), false); + + Assert::same($downloaded, 10); + } +} diff --git a/tests/Unit/Repository/GitHub/GitHubReleaseTest.php b/tests/Unit/Repository/GitHub/GitHubReleaseTest.php new file mode 100644 index 0000000..de01f2c --- /dev/null +++ b/tests/Unit/Repository/GitHub/GitHubReleaseTest.php @@ -0,0 +1,122 @@ +getName(), $name); + Assert::same($release->getVersion(), $tag); + Assert::same($release->getStability(), $stability); + Assert::same($release->getRepositoryName(), 'roadrunner-server/roadrunner'); + } + + #[DataSet(['v2024.1.0', '2024.*', true])] + #[DataSet(['v2024.1.0', '^2023.3', false])] + #[DataSet(['v2024.1.0', '>=2.0 <2025', true])] + #[DataSet(['v2024.1.0-rc.1', '2024.*', true], 'wildcard matches pre-releases')] + public function satisfiesComparesNormalizedName(string $tag, string $constraint, bool $expected): void + { + Assert::same(Releases::release($tag)->satisfies($constraint), $expected); + } + + public function keepsAssets(): void + { + $release = Releases::release('v2024.1.0', ['a.zip', 'b.zip']); + + Assert::same( + \array_map(static fn(AssetInterface $asset): string => $asset->getName(), $release->getAssets()->toArray()), + ['a.zip', 'b.zip'], + ); + } + + public function fromApiResponseUsesTagName(): void + { + $release = GitHubRelease::fromApiResponse(self::repository(), new MockHttpClient(), [ + 'name' => 'Release 2024.1.0', + 'tag_name' => 'v2024.1.0', + 'assets' => [ + ['name' => 'roadrunner-2024.1.0-linux-amd64.tar.gz', 'browser_download_url' => 'https://example.com/rr.tar.gz'], + ], + ]); + + Assert::same($release->getName(), '2024.1.0'); + Assert::same($release->getVersion(), 'v2024.1.0'); + Assert::same($release->getRepositoryName(), 'roadrunner-server/roadrunner'); + Assert::same($release->getAssets()->first()?->getUri(), 'https://example.com/rr.tar.gz'); + } + + public function fromApiResponseFallsBackToNameForInvalidTag(): void + { + $release = GitHubRelease::fromApiResponse(self::repository(), new MockHttpClient(), [ + 'name' => 'v2.1.0', + 'tag_name' => 'latest-build', + ]); + + Assert::same($release->getName(), '2.1.0'); + Assert::same($release->getVersion(), 'latest-build'); + Assert::true($release->getAssets()->empty()); + } + + #[Skip('Bug: Release::simplifyReleaseName() strips the last two characters of the first "-" segment, so the "dev-nightly" fallback name becomes "d-nightly"')] + public function fromApiResponseNamesUnparsableReleaseAsDevBranch(): void + { + $release = GitHubRelease::fromApiResponse(self::repository(), new MockHttpClient(), [ + 'name' => 'Nightly', + 'tag_name' => 'nightly', + ]); + + Assert::same($release->getName(), 'dev-nightly'); + } + + public function fromApiResponseRequiresName(): never + { + Expect::exception(\InvalidArgumentException::class) + ->withMessageContaining('"name"'); + + GitHubRelease::fromApiResponse(self::repository(), new MockHttpClient(), ['tag_name' => 'v2024.1.0']); + } + + public function getConfigDownloadsRrYamlOfTheTag(): void + { + $requested = null; + $client = new MockHttpClient(static function (string $method, string $url) use (&$requested): MockResponse { + $requested = $method . ' ' . $url; + + return new MockResponse("version: '3'\n"); + }); + + $config = Releases::release('v2024.1.0', [], $client)->getConfig(); + + Assert::same($config, "version: '3'\n"); + Assert::same($requested, 'GET https://raw.githubusercontent.com/roadrunner-server/roadrunner/v2024.1.0/.rr.yaml'); + } + + private static function repository(): GitHubRepository + { + return GitHubRepository::create('roadrunner-server', 'roadrunner', new MockHttpClient()); + } +} diff --git a/tests/Unit/Repository/GitHub/GitHubRepositoryTest.php b/tests/Unit/Repository/GitHub/GitHubRepositoryTest.php new file mode 100644 index 0000000..b0a6989 --- /dev/null +++ b/tests/Unit/Repository/GitHub/GitHubRepositoryTest.php @@ -0,0 +1,62 @@ +getName(), 'roadrunner-server/roadrunner'); + } + + public function getReleasesFollowsPagination(): void + { + $requests = []; + $pages = [ + 1 => [[['name' => 'v2024.2.0', 'tag_name' => 'v2024.2.0']], '; rel="next"'], + 2 => [[['name' => 'v2024.1.0', 'tag_name' => 'v2024.1.0']], '; rel="prev"'], + ]; + $client = new MockHttpClient( + static function (string $method, string $url, array $options) use (&$requests, $pages): MockResponse { + $requests[] = [$method, $url, $options['normalized_headers']['accept'][0] ?? null]; + \parse_str((string) \parse_url($url, \PHP_URL_QUERY), $query); + [$body, $link] = $pages[(int) $query['page']]; + + return new MockResponse(\json_encode($body), ['response_headers' => ['link' => $link]]); + }, + ); + + $releases = GitHubRepository::create('roadrunner-server', 'roadrunner', $client)->getReleases(); + + Assert::same(Releases::versions($releases), ['v2024.2.0', 'v2024.1.0']); + Assert::same($requests, [ + ['GET', 'https://api.github.com/repos/roadrunner-server/roadrunner/releases?page=1', 'accept: application/vnd.github.v3+json'], + ['GET', 'https://api.github.com/repos/roadrunner-server/roadrunner/releases?page=2', 'accept: application/vnd.github.v3+json'], + ]); + } + + public function getReleasesStopsWithoutLinkHeader(): void + { + $calls = 0; + $client = new MockHttpClient(static function () use (&$calls): MockResponse { + ++$calls; + + return new MockResponse('[]'); + }); + + $releases = GitHubRepository::create('roadrunner-server', 'roadrunner', $client)->getReleases(); + + Assert::true($releases->empty()); + Assert::same($calls, 1); + } +} diff --git a/tests/Unit/Repository/ReleasesCollectionTest.php b/tests/Unit/Repository/ReleasesCollectionTest.php new file mode 100644 index 0000000..fcccc46 --- /dev/null +++ b/tests/Unit/Repository/ReleasesCollectionTest.php @@ -0,0 +1,111 @@ +satisfies('^2024.1'); + + Assert::same(Releases::versions($releases), ['v2024.1.0', 'v2024.2.1']); + } + + public function satisfiesAppliesEveryConstraint(): void + { + $releases = Releases::collection('v2023.3.0', 'v2024.1.0', 'v2024.2.1')->satisfies('>=2024.1', '<2024.2'); + + Assert::same(Releases::versions($releases), ['v2024.1.0']); + } + + public function pipeSeparatedConstraintsAllApply(): void + { + $releases = Releases::collection('v2023.3.0', 'v2024.1.0', 'v2024.2.1')->satisfies('>=2024.1 | <2024.2 |'); + + Assert::same(Releases::versions($releases), ['v2024.1.0']); + } + + public function notSatisfiesDropsMatchingReleases(): void + { + $releases = Releases::collection('v2023.3.0', 'v2024.1.0', 'v2024.2.1')->notSatisfies('^2024.1'); + + Assert::same(Releases::versions($releases), ['v2023.3.0']); + } + + public function withAssetsDropsEmptyReleases(): void + { + $releases = new ReleasesCollection([ + Releases::release('v2024.1.0', ['roadrunner-2024.1.0-linux-amd64.tar.gz']), + Releases::release('v2024.1.1'), + ]); + + Assert::same(Releases::versions($releases->withAssets()), ['v2024.1.0']); + } + + public function sortByVersionOrdersStableReleasesDescending(): void + { + $releases = Releases::collection('v2023.3.12', 'v2024.2.0', 'v1.9.0', 'v2024.10.1', 'v2.12.3'); + + Assert::same( + Releases::versions($releases->sortByVersion()), + ['v2024.10.1', 'v2024.2.0', 'v2023.3.12', 'v2.12.3', 'v1.9.0'], + ); + } + + public function sortByVersionPutsReleaseCandidateBelowFinalRelease(): void + { + $releases = Releases::collection('v2024.1.0-rc.1', 'v2023.3.12', 'v2024.1.0'); + + Assert::same( + Releases::versions($releases->sortByVersion()), + ['v2024.1.0', 'v2024.1.0-rc.1', 'v2023.3.12'], + ); + } + + #[Skip('Bug: sortByVersion() replaces "-beta"/"-alpha"/"-RC" with a numeric weight, so v2024.1.0-beta.1 compares as 2024.1.0.2.1 and sorts above v2024.1.0')] + public function sortByVersionPutsPreReleasesBelowFinalRelease(): void + { + $releases = Releases::collection('v2024.1.0-alpha.1', 'v2024.1.0', 'v2024.1.0-beta.1', 'v2024.1.0-rc.1'); + + Assert::same( + Releases::versions($releases->sortByVersion()), + ['v2024.1.0', 'v2024.1.0-rc.1', 'v2024.1.0-beta.1', 'v2024.1.0-alpha.1'], + ); + } + + public function stableKeepsOnlyStableReleases(): void + { + $releases = Releases::collection('v2024.1.0-rc.1', 'v2024.1.0', 'v2024.1.0-beta.1'); + + Assert::same(Releases::versions($releases->stable()), ['v2024.1.0']); + } + + public function stabilityKeepsExactStability(): void + { + $releases = Releases::collection('v2024.1.0-rc.1', 'v2024.1.0', 'v2024.1.0-beta.1'); + + Assert::same(Releases::versions($releases->stability(Stability::STABILITY_BETA)), ['v2024.1.0-beta.1']); + } + + #[DataSet([Stability::STABILITY_STABLE, ['v2024.1.0']])] + #[DataSet([Stability::STABILITY_RC, ['v2024.1.0-rc.1', 'v2024.1.0']])] + #[DataSet([Stability::STABILITY_BETA, ['v2024.1.0-rc.1', 'v2024.1.0', 'v2024.1.0-beta.1']])] + #[DataSet([Stability::STABILITY_DEV, ['v2024.1.0-rc.1', 'v2024.1.0', 'v2024.1.0-beta.1', 'v2024.1.0-alpha.1']])] + public function minimumStabilityKeepsEqualOrMoreStable(string $stability, array $expected): void + { + $releases = Releases::collection('v2024.1.0-rc.1', 'v2024.1.0', 'v2024.1.0-beta.1', 'v2024.1.0-alpha.1'); + + Assert::same(Releases::versions($releases->minimumStability($stability)), $expected); + } +} diff --git a/tests/Unit/Repository/RepositoriesCollectionTest.php b/tests/Unit/Repository/RepositoriesCollectionTest.php new file mode 100644 index 0000000..3af9582 --- /dev/null +++ b/tests/Unit/Repository/RepositoriesCollectionTest.php @@ -0,0 +1,41 @@ +getName(), 'unknown/unknown'); + } + + public function returnsReleasesOfSingleRepository(): void + { + $collection = new RepositoriesCollection([ + new InMemoryRepository(Releases::release('v2024.1.0'), Releases::release('v2024.2.0')), + ]); + + Assert::same(Releases::versions($collection->getReleases()), ['v2024.1.0', 'v2024.2.0']); + } + + #[Skip('Bug: getReleases() collects the generator with preserved keys, so releases of a later repository overwrite earlier ones with the same index')] + public function mergesReleasesOfAllRepositories(): void + { + $collection = new RepositoriesCollection([ + new InMemoryRepository(Releases::release('v2024.1.0')), + new InMemoryRepository(Releases::release('v2.12.3')), + ]); + + Assert::same(Releases::versions($collection->getReleases()), ['v2024.1.0', 'v2.12.3']); + } +} diff --git a/tests/Unit/Repository/Version1/StaticRepositoryTest.php b/tests/Unit/Repository/Version1/StaticRepositoryTest.php new file mode 100644 index 0000000..52c610a --- /dev/null +++ b/tests/Unit/Repository/Version1/StaticRepositoryTest.php @@ -0,0 +1,42 @@ +getReleases(); + + Assert::same($repository->getName(), 'spiral/roadrunner'); + Assert::same($releases->first()?->getVersion(), 'v1.9.1'); + Assert::true($releases->satisfies('^2.0')->empty()); + } + + public function exposesAssetsOfLegacyReleases(): void + { + $asset = (new StaticRepository(new MockHttpClient())) + ->getReleases() + ->first() + ?->getAssets() + ->whereOperatingSystem('linux') + ->whereArchitecture('amd64') + ->first(); + + Assert::same($asset?->getName(), 'roadrunner-1.9.1-linux-amd64.tar.gz'); + Assert::same( + $asset?->getUri(), + 'https://github.com/spiral/roadrunner/releases/download/v1.9.1/roadrunner-1.9.1-linux-amd64.tar.gz', + ); + } +} diff --git a/tests/Unit/Stub/InMemoryRepository.php b/tests/Unit/Stub/InMemoryRepository.php new file mode 100644 index 0000000..34e41d0 --- /dev/null +++ b/tests/Unit/Stub/InMemoryRepository.php @@ -0,0 +1,31 @@ + */ + private array $releases; + + public function __construct( + ReleaseInterface ...$releases, + ) { + $this->releases = \array_values($releases); + } + + public function getName(): string + { + return 'roadrunner-server/roadrunner'; + } + + public function getReleases(): ReleasesCollection + { + return new ReleasesCollection($this->releases); + } +} diff --git a/tests/Unit/Stub/OptionHost.php b/tests/Unit/Stub/OptionHost.php new file mode 100644 index 0000000..c916f53 --- /dev/null +++ b/tests/Unit/Stub/OptionHost.php @@ -0,0 +1,47 @@ +command = new Command('test'); + $this->output = new BufferedOutput(); + } + + /** + * @param array $parameters + */ + public function input(array $parameters = []): InputInterface + { + $input = new ArrayInput($parameters, $this->command->getDefinition()); + $input->setInteractive(false); + + return $input; + } + + public function io(InputInterface $input): SymfonyStyle + { + return new SymfonyStyle($input, $this->output); + } + + public function display(): string + { + return $this->output->fetch(); + } +} diff --git a/tests/Unit/Stub/Releases.php b/tests/Unit/Stub/Releases.php new file mode 100644 index 0000000..f706609 --- /dev/null +++ b/tests/Unit/Stub/Releases.php @@ -0,0 +1,62 @@ + $assets Asset file names. + */ + public static function release( + string $tag, + array $assets = [], + ?HttpClientInterface $client = null, + string $repository = 'roadrunner-server/roadrunner', + ): GitHubRelease { + $client ??= new MockHttpClient(); + + return new GitHubRelease( + $client, + $tag, + $tag, + $repository, + \array_map(static fn(string $name): GitHubAsset => self::asset($name, $client), $assets), + ); + } + + public static function asset(string $name, ?HttpClientInterface $client = null): GitHubAsset + { + return new GitHubAsset($client ?? new MockHttpClient(), $name, 'https://example.com/download/' . $name); + } + + public static function collection(string ...$tags): ReleasesCollection + { + return new ReleasesCollection(\array_map(static fn(string $tag): GitHubRelease => self::release($tag), $tags)); + } + + /** + * @param iterable $releases + * @return list + */ + public static function versions(iterable $releases): array + { + $result = []; + foreach ($releases as $release) { + $result[] = $release->getVersion(); + } + + return $result; + } +} diff --git a/tests/Unit/Stub/TempDirectory.php b/tests/Unit/Stub/TempDirectory.php new file mode 100644 index 0000000..19ea1cc --- /dev/null +++ b/tests/Unit/Stub/TempDirectory.php @@ -0,0 +1,70 @@ +isDir() ? @\rmdir($item->getPathname()) : @\unlink($item->getPathname()); + } + + @\rmdir($path); + } + + /** + * Creates an archive with the given files and returns its path. + * + * @param non-empty-string $name File name; ".zip", ".tar.gz" and ".tar" are supported. + * @param array $files + */ + public static function archive(string $directory, string $name, array $files): string + { + $path = $directory . '/' . $name; + + if (\str_ends_with($name, '.tar.gz')) { + // Gzip by hand: an archive produced by PharData::compress() stays cached in the phar + // registry, and PHP 8.2/8.3 then read its entries back as empty files. + $tar = $directory . '/' . \bin2hex(\random_bytes(4)) . '.tar'; + self::fill(new \PharData($tar), $files); + \file_put_contents($path, \gzencode((string) \file_get_contents($tar))); + \unlink($tar); + + return $path; + } + + self::fill(new \PharData($path), $files); + + return $path; + } + + /** + * @param array $files + */ + private static function fill(\PharData $archive, array $files): void + { + foreach ($files as $file => $content) { + $archive->addFromString($file, $content); + } + } +} diff --git a/tests/Unit/VersionsCommandTest.php b/tests/Unit/VersionsCommandTest.php new file mode 100644 index 0000000..55e52a4 --- /dev/null +++ b/tests/Unit/VersionsCommandTest.php @@ -0,0 +1,123 @@ +getName(), 'versions'); + Assert::same($command->getDescription(), 'Returns a list of all available RoadRunner versions'); + } + + public function listsReleasesWithBinariesNewestFirst(): void + { + $major = self::compatibleMajor(); + + $display = self::run( + ['--os' => 'linux', '--arch' => 'amd64'], + Releases::release("v$major.1.0", ["roadrunner-$major.1.0-linux-amd64.tar.gz"]), + Releases::release("v$major.2.0", ["roadrunner-$major.2.0-linux-amd64.tar.gz", "roadrunner-$major.2.0-darwin-arm64.tar.gz"]), + Releases::release("v$major.3.0"), + ); + + Assert::string($display) + ->matchesRegex("/$major\\.2\\.0\\s+stable\\s+✓\\s+\\(2\\)\\s+✓\\s*\\n\\s*$major\\.1\\.0\\s+stable\\s+✓\\s+\\(1\\)\\s+✓/u"); + Assert::string($display)->notContains("$major.3.0"); + } + + public function explainsMissingAssembly(): void + { + $major = self::compatibleMajor(); + + $display = self::run( + ['--os' => 'darwin', '--arch' => 'amd64'], + Releases::release("v$major.1.0", ["roadrunner-$major.1.0-linux-amd64.tar.gz"]), + Releases::release("v$major.2.0", ["roadrunner-$major.2.0-darwin-arm64.tar.gz"]), + ); + + Assert::string($display) + ->contains('(reason: no assembly for darwin)') + ->contains('(reason: no assembly for amd64)'); + } + + public function marksIncompatibleVersions(): void + { + if (Version::constraint() === '*') { + throw new SkipTest('Every version is compatible when RoadRunner is installed from a branch'); + } + + $display = self::run( + ['--os' => 'linux', '--arch' => 'amd64'], + Releases::release('v1.9.0', ['roadrunner-1.9.0-linux-amd64.tar.gz']), + ); + + Assert::string($display)->contains('(reason: incompatible version)'); + } + + public function filtersByStabilityAndVersion(): void + { + $display = self::run( + ['--os' => 'linux', '--arch' => 'amd64', '--stability' => 'beta', '--filter' => '^2024.1'], + Releases::release('v2024.1.0-beta.1', ['roadrunner-2024.1.0-beta.1-linux-amd64.tar.gz']), + Releases::release('v2024.1.0-alpha.1', ['roadrunner-2024.1.0-alpha.1-linux-amd64.tar.gz']), + Releases::release('v2023.3.12', ['roadrunner-2023.3.12-linux-amd64.tar.gz']), + ); + + Assert::string($display) + ->contains('2024.1.0-beta1') + ->notContains('alpha1') + ->notContains('2023.3.12'); + } + + /** + * Major version of a release that satisfies the installed RoadRunner constraint. + */ + private static function compatibleMajor(): string + { + $constraint = Version::constraint(); + + return $constraint === '*' ? '2024' : \substr($constraint, 0, -2); + } + + /** + * @param array $input + */ + private static function run(array $input, ReleaseInterface ...$releases): string + { + $repository = new InMemoryRepository(...$releases); + $command = new class($repository) extends VersionsCommand { + public function __construct( + private readonly RepositoryInterface $repository, + ) { + parent::__construct(); + } + + protected function getRepository(): RepositoryInterface + { + return $this->repository; + } + }; + + $tester = new CommandTester($command); + Assert::same($tester->execute($input, ['interactive' => false]), 0); + + return $tester->getDisplay(); + } +}