From c89353b7e05372322593d355127bce4ecb3dedf9 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Fri, 9 Oct 2026 21:07:23 +0400 Subject: [PATCH 1/4] test: cover package discovery, process execution and version edge cases The two InstalledTest helpers that checked RR_VERSION precedence over `rr --version` were never discovered by Testo; they now run as tests. Two skipped tests document bugs found along the way: Package derives the minimum of `^2025.1 || ^3.0` as 3.0 instead of 2025.1, and Comparator::compare() mis-orders unnormalized input such as `v3.0.1` or `3.0`. Assisted-By: Claude Opus 5.5 --- tests/fixtures/packages/branch/composer.json | 6 ++ .../fixtures/packages/calendar/composer.json | 6 ++ tests/fixtures/packages/invalid/composer.json | 6 ++ .../packages/no-composer-json/.gitkeep | 0 .../packages/no-require/composer.json | 3 + tests/fixtures/packages/stable/composer.json | 6 ++ .../fixtures/packages/wildcard/composer.json | 6 ++ tests/src/Unit/Composer/PackageTest.php | 68 +++++++++++++++++++ tests/src/Unit/Process/ProcessTest.php | 32 +++++++++ tests/src/Unit/Version/ComparatorTest.php | 38 +++++++++++ tests/src/Unit/Version/InstalledTest.php | 33 ++++++++- tests/src/Unit/Version/RequiredTest.php | 31 +++++++++ tests/src/Unit/VersionCheckerTest.php | 29 ++++++++ 13 files changed, 261 insertions(+), 3 deletions(-) create mode 100644 tests/fixtures/packages/branch/composer.json create mode 100644 tests/fixtures/packages/calendar/composer.json create mode 100644 tests/fixtures/packages/invalid/composer.json create mode 100644 tests/fixtures/packages/no-composer-json/.gitkeep create mode 100644 tests/fixtures/packages/no-require/composer.json create mode 100644 tests/fixtures/packages/stable/composer.json create mode 100644 tests/fixtures/packages/wildcard/composer.json create mode 100644 tests/src/Unit/Process/ProcessTest.php 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..ac418a8 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\Lifecycle\AfterTest; +use Testo\Skip; 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,16 @@ public function testGetMinVersion(string $version, string $expected): void Assert::same($ref->invoke($package, $version), $expected); } + #[Skip('Bug: the lower bound comes from numeric semver order, where 3.0 < 2023.1, not from RoadRunner release order')] + public function testGetMinVersionFollowsReleaseOrderAcrossReleaseLines(): void + { + $package = new Package(); + $ref = new \ReflectionMethod($package, 'getMinVersion'); + $ref->setAccessible(true); + + Assert::same($ref->invoke($package, '^2025.1 || ^3.0'), '2025.1.0.0-dev'); + } + public static function isSupportedVersionDataProvider(): \Traversable { yield ['1.0', true]; @@ -47,6 +97,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 +117,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..a226174 100644 --- a/tests/src/Unit/Version/ComparatorTest.php +++ b/tests/src/Unit/Version/ComparatorTest.php @@ -7,6 +7,9 @@ use RoadRunner\VersionChecker\Version\Comparator; use Testo\Assert; use Testo\Data\DataProvider; +use Testo\Data\DataSet; +use Testo\Expect; +use Testo\Skip; use Testo\Test; #[Test] @@ -64,6 +67,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 +102,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 +126,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 +137,25 @@ public function testCompare(string $a, string $b, int $expected): void Assert::same(Comparator::compare($a, $b), $expected); } + #[Skip('Bug: compare() handles the `v` prefix only when picking the release line; within a line the raw strings go to composer/semver unnormalized')] + #[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 +166,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..020f719 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; @@ -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'); @@ -104,6 +110,27 @@ public function testGetInstalledVersionUnableToDetermineVersion(): void $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..de9101f 100644 --- a/tests/src/Unit/Version/RequiredTest.php +++ b/tests/src/Unit/Version/RequiredTest.php @@ -44,6 +44,27 @@ public function testGetRequiredVersion(): void Assert::same($required->getRequiredVersion(), '2.0.0.0-dev'); } + #[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 { $package = Mockery::mock(PackageInterface::class)->shouldIgnoreMissing(); @@ -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(); From 252add38748710784328ec3f49110c8cd09e72f8 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Fri, 9 Oct 2026 21:10:51 +0400 Subject: [PATCH 2/4] fix: take the minimum of `||` constraints in RoadRunner release order Composer orders 3.0 below 2023.1, so a package requiring `^2025.1 || ^3.0` made 3.0 the required minimum and rejected an installed 2025.x. Assisted-By: Claude Opus 5.5 --- src/Composer/Package.php | 28 ++++++++++++++++++++++--- tests/src/Unit/Composer/PackageTest.php | 11 ++++++---- 2 files changed, 32 insertions(+), 7 deletions(-) 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/tests/src/Unit/Composer/PackageTest.php b/tests/src/Unit/Composer/PackageTest.php index ac418a8..42c7442 100644 --- a/tests/src/Unit/Composer/PackageTest.php +++ b/tests/src/Unit/Composer/PackageTest.php @@ -8,8 +8,8 @@ use RoadRunner\VersionChecker\Composer\Package; use Testo\Assert; use Testo\Data\DataProvider; +use Testo\Data\DataSet; use Testo\Lifecycle\AfterTest; -use Testo\Skip; use Testo\Test; #[Test] @@ -72,14 +72,17 @@ public function testGetMinVersion(string $version, string $expected): void Assert::same($ref->invoke($package, $version), $expected); } - #[Skip('Bug: the lower bound comes from numeric semver order, where 3.0 < 2023.1, not from RoadRunner release order')] - public function testGetMinVersionFollowsReleaseOrderAcrossReleaseLines(): void + #[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, '^2025.1 || ^3.0'), '2025.1.0.0-dev'); + Assert::same($ref->invoke($package, $version), $expected); } public static function isSupportedVersionDataProvider(): \Traversable From decb2339df5c7b0ba53099620f5397d84671eedc Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Fri, 9 Oct 2026 21:10:52 +0400 Subject: [PATCH 3/4] fix: normalize versions in `Comparator::compare()` Within a release line the raw strings went to composer/semver, so direct callers got `v3.0.1` older than `3.0.0` and `3.0` older than `3.0.0`. Assisted-By: Claude Opus 5.5 --- src/Version/Comparator.php | 6 ++++++ tests/src/Unit/Version/ComparatorTest.php | 2 -- tests/src/Unit/Version/RequiredTest.php | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) 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/src/Unit/Version/ComparatorTest.php b/tests/src/Unit/Version/ComparatorTest.php index a226174..bc836e1 100644 --- a/tests/src/Unit/Version/ComparatorTest.php +++ b/tests/src/Unit/Version/ComparatorTest.php @@ -9,7 +9,6 @@ use Testo\Data\DataProvider; use Testo\Data\DataSet; use Testo\Expect; -use Testo\Skip; use Testo\Test; #[Test] @@ -137,7 +136,6 @@ public function testCompare(string $a, string $b, int $expected): void Assert::same(Comparator::compare($a, $b), $expected); } - #[Skip('Bug: compare() handles the `v` prefix only when picking the release line; within a line the raw strings go to composer/semver unnormalized')] #[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')] diff --git a/tests/src/Unit/Version/RequiredTest.php b/tests/src/Unit/Version/RequiredTest.php index de9101f..4c0043a 100644 --- a/tests/src/Unit/Version/RequiredTest.php +++ b/tests/src/Unit/Version/RequiredTest.php @@ -41,7 +41,7 @@ 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')] From 220082f1b3bff58e33962351c07a9b0926e97388 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Fri, 9 Oct 2026 21:11:00 +0400 Subject: [PATCH 4/4] test: isolate `Installed` tests from `RR_VERSION` in the environment Assisted-By: Claude Opus 5.5 --- tests/src/Unit/Version/InstalledTest.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/src/Unit/Version/InstalledTest.php b/tests/src/Unit/Version/InstalledTest.php index 020f719..94ac47b 100644 --- a/tests/src/Unit/Version/InstalledTest.php +++ b/tests/src/Unit/Version/InstalledTest.php @@ -36,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); } @@ -92,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(); @@ -104,7 +104,7 @@ 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();