diff --git a/src/Composer/Package.php b/src/Composer/Package.php index bec0ec1..38c89a4 100644 --- a/src/Composer/Package.php +++ b/src/Composer/Package.php @@ -5,7 +5,10 @@ namespace RoadRunner\VersionChecker\Composer; use Composer\InstalledVersions; +use Composer\Semver\Constraint\ConstraintInterface; +use Composer\Semver\Constraint\MultiConstraint; use Composer\Semver\VersionParser; +use RoadRunner\VersionChecker\Version\Comparator; final class Package implements PackageInterface { @@ -65,11 +68,30 @@ private function getMinVersion(string $version): string { $parser = new VersionParser(); - $constraint = $parser->parseConstraints($version); - /** @var non-empty-string $min */ - $min = $constraint->getLowerBound()->getVersion(); + $min = $this->getLowerBound($parser->parseConstraints($version)); return $min; } + + /** + * Composer orders `3.0` below `2023.1`, so the lowest alternative of an `||` constraint + * is picked in RoadRunner release order instead. + */ + private function getLowerBound(ConstraintInterface $constraint): string + { + if (!$constraint instanceof MultiConstraint || !$constraint->isDisjunctive()) { + return $constraint->getLowerBound()->getVersion(); + } + + $min = null; + foreach ($constraint->getConstraints() as $alternative) { + $bound = $this->getLowerBound($alternative); + if ($min === null || Comparator::compare($bound, $min) < 0) { + $min = $bound; + } + } + + return $min ?? $constraint->getLowerBound()->getVersion(); + } } diff --git a/src/Version/Comparator.php b/src/Version/Comparator.php index d3ea566..f7b4c39 100644 --- a/src/Version/Comparator.php +++ b/src/Version/Comparator.php @@ -26,9 +26,15 @@ public function __construct(?VersionParser $parser = null) * Compares two RoadRunner versions in release order. * * @return int<-1, 1> Negative when $a is older than $b, positive when newer, zero when equal. + * + * @throws \UnexpectedValueException When a version cannot be parsed. */ public static function compare(string $a, string $b): int { + $parser = new VersionParser(); + $a = $parser->normalize($a); + $b = $parser->normalize($b); + $lineA = self::releaseLine($a); $lineB = self::releaseLine($b); diff --git a/tests/fixtures/packages/branch/composer.json b/tests/fixtures/packages/branch/composer.json new file mode 100644 index 0000000..c5245e1 --- /dev/null +++ b/tests/fixtures/packages/branch/composer.json @@ -0,0 +1,6 @@ +{ + "name": "fixture/branch", + "require": { + "fixture/roadrunner": "dev-master" + } +} diff --git a/tests/fixtures/packages/calendar/composer.json b/tests/fixtures/packages/calendar/composer.json new file mode 100644 index 0000000..3590e17 --- /dev/null +++ b/tests/fixtures/packages/calendar/composer.json @@ -0,0 +1,6 @@ +{ + "name": "fixture/calendar", + "require": { + "fixture/roadrunner": "^2023.1 || ^2024.1" + } +} diff --git a/tests/fixtures/packages/invalid/composer.json b/tests/fixtures/packages/invalid/composer.json new file mode 100644 index 0000000..e1cb4d4 --- /dev/null +++ b/tests/fixtures/packages/invalid/composer.json @@ -0,0 +1,6 @@ +{ + "name": "fixture/invalid", + "require": { + "fixture/roadrunner": "latest" + } +} diff --git a/tests/fixtures/packages/no-composer-json/.gitkeep b/tests/fixtures/packages/no-composer-json/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/packages/no-require/composer.json b/tests/fixtures/packages/no-require/composer.json new file mode 100644 index 0000000..beb4bfa --- /dev/null +++ b/tests/fixtures/packages/no-require/composer.json @@ -0,0 +1,3 @@ +{ + "name": "fixture/no-require" +} diff --git a/tests/fixtures/packages/stable/composer.json b/tests/fixtures/packages/stable/composer.json new file mode 100644 index 0000000..4cd253b --- /dev/null +++ b/tests/fixtures/packages/stable/composer.json @@ -0,0 +1,6 @@ +{ + "name": "fixture/stable", + "require": { + "fixture/roadrunner": "^2.12" + } +} diff --git a/tests/fixtures/packages/wildcard/composer.json b/tests/fixtures/packages/wildcard/composer.json new file mode 100644 index 0000000..b6e06ca --- /dev/null +++ b/tests/fixtures/packages/wildcard/composer.json @@ -0,0 +1,6 @@ +{ + "name": "fixture/wildcard", + "require": { + "fixture/roadrunner": "*" + } +} diff --git a/tests/src/Unit/Composer/PackageTest.php b/tests/src/Unit/Composer/PackageTest.php index 0b13af2..42c7442 100644 --- a/tests/src/Unit/Composer/PackageTest.php +++ b/tests/src/Unit/Composer/PackageTest.php @@ -4,14 +4,54 @@ namespace RoadRunner\VersionChecker\Tests\Unit\Composer; +use Composer\InstalledVersions; use RoadRunner\VersionChecker\Composer\Package; use Testo\Assert; use Testo\Data\DataProvider; +use Testo\Data\DataSet; +use Testo\Lifecycle\AfterTest; use Testo\Test; #[Test] final class PackageTest { + private const FIXTURES = __DIR__ . '/../../../fixtures/packages'; + + #[AfterTest] + protected function restoreInstalledVersions(): void + { + // Null drops the override, so the next lookup reads vendor/composer/installed.php again. + InstalledVersions::reload(null); + } + + public function testGetRequiredVersionsReturnsLowerBoundsOfSupportedConstraints(): void + { + InstalledVersions::reload([ + 'root' => self::installedPackage('fixture/root', self::FIXTURES . '/no-require') + ['dev' => true], + 'versions' => [ + 'fixture/stable' => self::installedPackage('fixture/stable', self::FIXTURES . '/stable'), + 'fixture/calendar' => self::installedPackage('fixture/calendar', self::FIXTURES . '/calendar'), + 'fixture/wildcard' => self::installedPackage('fixture/wildcard', self::FIXTURES . '/wildcard'), + 'fixture/branch' => self::installedPackage('fixture/branch', self::FIXTURES . '/branch'), + 'fixture/invalid' => self::installedPackage('fixture/invalid', self::FIXTURES . '/invalid'), + 'fixture/no-require' => self::installedPackage('fixture/no-require', self::FIXTURES . '/no-require'), + 'fixture/no-composer-json' => self::installedPackage('fixture/no-composer-json', self::FIXTURES . '/no-composer-json'), + 'fixture/metapackage' => ['type' => 'metapackage', 'install_path' => null] + self::installedPackage('fixture/metapackage', ''), + ], + ]); + + $versions = (new Package())->getRequiredVersions('fixture/roadrunner'); + + Assert::array($versions)->sameElementsAs(['2.12.0.0-dev', '2023.1.0.0-dev', '0.0.0.0-dev']); + } + + public function testGetRequiredVersionsReturnsEmptyListWhenNothingRequiresPackage(): void + { + $versions = (new Package())->getRequiredVersions('fixture/not-required-by-anything'); + + Assert::same($versions, []); + } + #[DataProvider('isSupportedVersionDataProvider')] public function testIsSupportedVersion(string $version, bool $expected): void { @@ -32,6 +72,19 @@ public function testGetMinVersion(string $version, string $expected): void Assert::same($ref->invoke($package, $version), $expected); } + #[DataSet(['^2025.1 || ^3.0', '2025.1.0.0-dev'], 'calendar or 3.x')] + #[DataSet(['^3.0 || ^2025.1', '2025.1.0.0-dev'], '3.x or calendar')] + #[DataSet(['^3.0 || ^2024.1 || ^2.12', '2.12.0.0-dev'], 'all release lines')] + #[DataSet(['>=2025.1 <2026.0 || >=3.0', '2025.1.0.0-dev'], 'explicit ranges')] + public function testGetMinVersionFollowsReleaseOrderAcrossReleaseLines(string $version, string $expected): void + { + $package = new Package(); + $ref = new \ReflectionMethod($package, 'getMinVersion'); + $ref->setAccessible(true); + + Assert::same($ref->invoke($package, $version), $expected); + } + public static function isSupportedVersionDataProvider(): \Traversable { yield ['1.0', true]; @@ -47,6 +100,8 @@ public static function isSupportedVersionDataProvider(): \Traversable yield ['dev-feature/some', false]; yield ['<2.0', true]; yield ['<=2.0', true]; + yield ['*', true]; + yield ['latest', false]; } public static function getMinVersionDataProvider(): \Traversable @@ -65,5 +120,21 @@ public static function getMinVersionDataProvider(): \Traversable yield ['1.0 - 2.0', '1.0.0.0-dev']; yield ['<2.0', '0.0.0.0-dev']; yield ['<=2.0', '0.0.0.0-dev']; + yield ['*', '0.0.0.0-dev']; + yield ['^2023.1 || ^2024.1', '2023.1.0.0-dev']; + } + + private static function installedPackage(string $name, string $installPath): array + { + return [ + 'name' => $name, + 'pretty_version' => '1.0.0', + 'version' => '1.0.0.0', + 'reference' => null, + 'type' => 'library', + 'install_path' => $installPath, + 'aliases' => [], + 'dev_requirement' => false, + ]; } } diff --git a/tests/src/Unit/Process/ProcessTest.php b/tests/src/Unit/Process/ProcessTest.php new file mode 100644 index 0000000..f9b2bd0 --- /dev/null +++ b/tests/src/Unit/Process/ProcessTest.php @@ -0,0 +1,32 @@ +exec([\PHP_BINARY, '-r', 'echo "rr version 2024.3.0";']); + + Assert::same($output, 'rr version 2024.3.0'); + } + + public function testExecThrowsOnNonZeroExitCode(): void + { + $process = new Process(); + + Expect::exception(ProcessFailedException::class); + $process->exec([\PHP_BINARY, '-r', 'exit(1);']); + } +} diff --git a/tests/src/Unit/Version/ComparatorTest.php b/tests/src/Unit/Version/ComparatorTest.php index 36ad071..bc836e1 100644 --- a/tests/src/Unit/Version/ComparatorTest.php +++ b/tests/src/Unit/Version/ComparatorTest.php @@ -7,6 +7,8 @@ use RoadRunner\VersionChecker\Version\Comparator; use Testo\Assert; use Testo\Data\DataProvider; +use Testo\Data\DataSet; +use Testo\Expect; use Testo\Test; #[Test] @@ -64,6 +66,16 @@ public static function greaterThanDataProvider(): \Traversable yield ['3.1.0', '3.0.0', false]; yield ['3.0.0', '3.1.0', true]; yield ['3.0.0-beta.1', '3.0.0', true]; + // '0' is satisfied by any installed release + yield ['0', '2.12.3', true]; + // a release candidate is older than its release + yield ['2023.1.0', '2023.1.0-rc.2', false]; + yield ['2023.1.0-rc.2', '2023.1.0', true]; + // the `v` prefix is ignored + yield ['v2023.1.0', '2023.1.0', true]; + yield ['v3.0.0', '2025.1.5', false]; + // a dev branch is older than any release + yield ['dev-master', '2.12.3', true]; } public static function lessThanDataProvider(): \Traversable @@ -89,6 +101,8 @@ public static function lessThanDataProvider(): \Traversable yield ['3.0', '2025.1.5', true]; yield ['2023.1', '2.12.3', true]; yield ['2.12', '2024.3.0', false]; + yield ['v3.0.0', '2025.1.5', true]; + yield ['2023.1.0', '2023.1.0-rc.2', true]; } public static function equalDataProvider(): \Traversable @@ -111,6 +125,9 @@ public static function equalDataProvider(): \Traversable yield ['2.0.0-alpha.1', '2.0.0-alpha.1', true]; yield ['3.0.0', 'v3.0.0', true]; yield ['2025.1.0', '3.0.0', false]; + yield ['v2023.1.0', '2023.1.0', true]; + yield ['3.0', '3.0.0', true]; + yield ['2023.1.0-rc.2', '2023.1.0-RC2', true]; } #[DataProvider('compareDataProvider')] @@ -119,6 +136,24 @@ public function testCompare(string $a, string $b, int $expected): void Assert::same(Comparator::compare($a, $b), $expected); } + #[DataSet(['v3.0.1', '3.0.0', 1], 'v prefix, newer patch')] + #[DataSet(['v2023.1.0', '2023.1.0', 0], 'v prefix, same version')] + #[DataSet(['3.0', '3.0.0', 0], 'short form of the same version')] + public function testCompareAcceptsUnnormalizedVersions(string $a, string $b, int $expected): void + { + Assert::same(Comparator::compare($a, $b), $expected); + } + + #[DataSet(['*'], 'wildcard')] + #[DataSet(['latest'], 'word')] + public function testRejectsNonVersionStrings(string $requested): void + { + $comparator = new Comparator(); + + Expect::exception(\UnexpectedValueException::class); + $comparator->greaterThan($requested, '3.0.0'); + } + public static function compareDataProvider(): \Traversable { yield ['3.0.0.0', '3.0.0.0', 0]; @@ -129,5 +164,6 @@ public static function compareDataProvider(): \Traversable yield ['2025.1.0.0', '2024.3.0.0', 1]; yield ['3.0.1.0', '3.0.0.0', 1]; yield ['dev-master', '3.0.0.0', -1]; + yield ['3.0.0.0', 'dev-master', 1]; } } diff --git a/tests/src/Unit/Version/InstalledTest.php b/tests/src/Unit/Version/InstalledTest.php index 1cf0242..94ac47b 100644 --- a/tests/src/Unit/Version/InstalledTest.php +++ b/tests/src/Unit/Version/InstalledTest.php @@ -6,12 +6,14 @@ use Mockery; use RoadRunner\VersionChecker\Environment\EnvironmentInterface; +use RoadRunner\VersionChecker\Environment\Native; use RoadRunner\VersionChecker\Exception\RoadrunnerNotInstalledException; use RoadRunner\VersionChecker\Process\ProcessInterface; use RoadRunner\VersionChecker\Version\Installed; use Symfony\Component\Process\Exception\ProcessFailedException; use Testo\Assert; use Testo\Data\DataProvider; +use Testo\Data\DataSet; use Testo\Expect; use Testo\Lifecycle\AfterTest; use Testo\Test; @@ -34,7 +36,7 @@ public function testGetInstalledVersion(string $version, string $output): void $process = Mockery::mock(ProcessInterface::class)->shouldIgnoreMissing(); $process->shouldReceive('exec')->once()->with(['./rr', '--version'], Mockery::andAnyOtherArgs())->andReturn($output); - $installed = new Installed($process); + $installed = new Installed($process, new Native(['RR_VERSION' => ''])); Assert::same($installed->getInstalledVersion(), $version); } @@ -54,7 +56,8 @@ public function testCachedVersion(): void Assert::same($version2, '2023.1.0'); } - public function getVersionFromEnv(): void + #[Test] + public function testEnvironmentVersionTakesPrecedenceOverConsoleCommand(): void { $env = Mockery::mock(EnvironmentInterface::class)->shouldIgnoreMissing(); $env->shouldReceive('get')->once()->with('RR_VERSION', Mockery::andAnyOtherArgs())->andReturn('2023.1.0'); @@ -67,10 +70,13 @@ public function getVersionFromEnv(): void Assert::same($installed->getInstalledVersion(), '2023.1.0'); } - public function getVersionFromConsoleCommand(): void + #[Test] + #[DataSet([null], 'not set')] + #[DataSet([''], 'empty string')] + public function testConsoleCommandIsUsedWithoutEnvironmentVersion(?string $envValue): void { $env = Mockery::mock(EnvironmentInterface::class)->shouldIgnoreMissing(); - $env->shouldReceive('get')->once()->with('RR_VERSION', Mockery::andAnyOtherArgs())->andReturn(null); + $env->shouldReceive('get')->once()->with('RR_VERSION', Mockery::andAnyOtherArgs())->andReturn($envValue); $process = Mockery::mock(ProcessInterface::class)->shouldIgnoreMissing(); $process->shouldReceive('exec')->once()->with(['./rr', '--version'], Mockery::andAnyOtherArgs())->andReturn('version 2023.1.0'); @@ -86,7 +92,7 @@ public function testGetInstalledVersionRoadRunnerIsNotInstalled(): void $process = Mockery::mock(ProcessInterface::class)->shouldIgnoreMissing(); $process->shouldReceive('exec')->once()->with(['./rr', '--version'], Mockery::andAnyOtherArgs())->andThrow((new \ReflectionClass(ProcessFailedException::class))->newInstanceWithoutConstructor()); - $installed = new Installed($process); + $installed = new Installed($process, new Native(['RR_VERSION' => ''])); Expect::exception(RoadrunnerNotInstalledException::class); $installed->getInstalledVersion(); @@ -98,12 +104,33 @@ public function testGetInstalledVersionUnableToDetermineVersion(): void $process = Mockery::mock(ProcessInterface::class)->shouldIgnoreMissing(); $process->shouldReceive('exec')->once()->with(['./rr', '--version'], Mockery::andAnyOtherArgs())->andReturn('foo'); - $installed = new Installed($process); + $installed = new Installed($process, new Native(['RR_VERSION' => ''])); Expect::exception(RoadrunnerNotInstalledException::class)->withMessageContaining('Unable to determine RoadRunner version.'); $installed->getInstalledVersion(); } + #[Test] + public function testConsoleCommandUsesExecutablePath(): void + { + $process = Mockery::mock(ProcessInterface::class)->shouldIgnoreMissing(); + $process->shouldReceive('exec')->once()->with(['/opt/bin/rr', '--version'], Mockery::andAnyOtherArgs())->andReturn('rr version 2024.3.0'); + + $installed = new Installed($process, new Native(['RR_VERSION' => '']), '/opt/bin/rr'); + + Assert::same($installed->getInstalledVersion(), '2024.3.0'); + } + + #[Test] + public function testMissingExecutableIsReportedAsNotInstalled(): void + { + $executable = \sys_get_temp_dir() . '/roadrunner-version-checker-missing/rr'; + $installed = new Installed(environment: new Native(['RR_VERSION' => '']), executablePath: $executable); + + Expect::exception(RoadrunnerNotInstalledException::class)->withMessageContaining($executable); + $installed->getInstalledVersion(); + } + public static function outputDataProvider(): \Traversable { yield ['2.12.3', 'rr version 2.12.3 (build time: 2023-02-16T13:08:23+0000, go1.20), OS: darwin, arch: arm64']; diff --git a/tests/src/Unit/Version/RequiredTest.php b/tests/src/Unit/Version/RequiredTest.php index 793231d..4c0043a 100644 --- a/tests/src/Unit/Version/RequiredTest.php +++ b/tests/src/Unit/Version/RequiredTest.php @@ -41,7 +41,28 @@ public function testGetRequiredVersion(): void $required = new Required($package); - Assert::same($required->getRequiredVersion(), '2.0.0.0-dev'); + Assert::same($required->getRequiredVersion(), '2.0'); + } + + #[DataProvider('releaseLinesDataProvider')] + public function testGetRequiredVersionPicksLatestReleaseLine(array $versions, string $expected): void + { + $package = Mockery::mock(PackageInterface::class)->shouldIgnoreMissing(); + $package->shouldReceive('getRequiredVersions')->once()->with('spiral/roadrunner', Mockery::andAnyOtherArgs())->andReturn($versions); + + $required = new Required($package); + + Assert::same($required->getRequiredVersion(), $expected); + } + + public function testGetRequiredVersionWithoutRoadRunnerRequirement(): void + { + $package = Mockery::mock(PackageInterface::class)->shouldIgnoreMissing(); + $package->shouldReceive('getRequiredVersions')->once()->with('spiral/roadrunner', Mockery::andAnyOtherArgs())->andReturn([]); + + $required = new Required($package); + + Assert::null($required->getRequiredVersion()); } public function testGetCachedVersion(): void @@ -55,6 +76,16 @@ public function testGetCachedVersion(): void Assert::same($required->getRequiredVersion(), '1.0'); } + public static function releaseLinesDataProvider(): \Traversable + { + yield '2.x, calendar, 3.x' => [['2.12.0.0-dev', '2023.1.0.0-dev', '3.0.0.0-dev'], '3.0.0.0-dev']; + yield '3.x, calendar, 2.x' => [['3.0.0.0-dev', '2025.1.0.0-dev', '2.12.0.0-dev'], '3.0.0.0-dev']; + yield 'calendar first' => [['2025.1.0.0-dev', '3.0.0.0-dev', '2023.1.0.0-dev'], '3.0.0.0-dev']; + yield 'calendar only' => [['2024.1.0.0-dev', '2023.1.0.0-dev', '2025.1.0.0-dev'], '2025.1.0.0-dev']; + yield 'wildcard and 2.x' => [['0.0.0.0-dev', '2.12.0.0-dev'], '2.12.0.0-dev']; + yield 'pre-release and its release' => [['3.0.0.0-beta1', '3.0.0.0'], '3.0.0.0']; + } + public static function versionsDataProvider(): \Traversable { // Test case with $previous === null diff --git a/tests/src/Unit/VersionCheckerTest.php b/tests/src/Unit/VersionCheckerTest.php index a883139..41fef33 100644 --- a/tests/src/Unit/VersionCheckerTest.php +++ b/tests/src/Unit/VersionCheckerTest.php @@ -7,6 +7,7 @@ use Mockery; use RoadRunner\VersionChecker\Exception\RequiredVersionException; use RoadRunner\VersionChecker\Exception\UnsupportedVersionException; +use RoadRunner\VersionChecker\Version\Comparator; use RoadRunner\VersionChecker\Version\ComparatorInterface; use RoadRunner\VersionChecker\Version\InstalledInterface; use RoadRunner\VersionChecker\Version\RequiredInterface; @@ -119,6 +120,34 @@ public function testFailGreaterThanWithVersion(): void Assert::same($exception->getMessage(), 'Installed RoadRunner version `1.0` not supported. Requires version `2.0` or higher.'); } + public function testGreaterThanTreatsZeroAsVersion(): void + { + $installedVersion = Mockery::mock(InstalledInterface::class)->shouldIgnoreMissing(); + $installedVersion->shouldReceive('getInstalledVersion')->once()->andReturn('2.12.3'); + + $requiredVersion = Mockery::mock(RequiredInterface::class)->shouldIgnoreMissing(); + $requiredVersion->shouldReceive('getRequiredVersion')->never(); + + $checker = new VersionChecker($installedVersion, $requiredVersion, new Comparator()); + + $checker->greaterThan('0'); + } + + public function testFailGreaterThanReportsRequiredVersionWithoutStability(): void + { + $installedVersion = Mockery::mock(InstalledInterface::class)->shouldIgnoreMissing(); + $installedVersion->shouldReceive('getInstalledVersion')->once()->andReturn('2.12.3'); + + $requiredVersion = Mockery::mock(RequiredInterface::class)->shouldIgnoreMissing(); + $requiredVersion->shouldReceive('getRequiredVersion')->once()->andReturn('2023.1.0.0-dev'); + + $checker = new VersionChecker($installedVersion, $requiredVersion, new Comparator()); + + Expect::exception(UnsupportedVersionException::class) + ->withMessage('Installed RoadRunner version `2.12.3` not supported. Requires version `2023.1.0` or higher.'); + $checker->greaterThan(); + } + public function testSuccessLessThan(): void { $comparator = Mockery::mock(ComparatorInterface::class)->shouldIgnoreMissing();