From e7b14e8878a8c6009404998d0413c749925cab30 Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Wed, 14 Oct 2026 21:05:23 +0100 Subject: [PATCH 1/8] 775: add feature test for updating an existing ext with pie install in php project --- features/install-in-php-project.feature | 6 ++++ test/behaviour/CliContext.php | 37 +++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/features/install-in-php-project.feature b/features/install-in-php-project.feature index 25774394..ac311951 100644 --- a/features/install-in-php-project.feature +++ b/features/install-in-php-project.feature @@ -11,3 +11,9 @@ Feature: Extensions for a PHP project can be installed with PIE Given I am in a PHP project that has missing extensions When I run a command to install the extensions without package selections Then I should see information on how to select packages for install + + # pie install + Example: PIE running in a PHP project with out of date extensions + Given I am in a PHP project that has an outdated extension + When I run a command to install the extensions with package selections + Then I should see the outdated extension is updated diff --git a/test/behaviour/CliContext.php b/test/behaviour/CliContext.php index 360bd79b..6f2c4dd3 100644 --- a/test/behaviour/CliContext.php +++ b/test/behaviour/CliContext.php @@ -489,6 +489,26 @@ public function theExtensionFailsToInstallDueToTheMissingLibrary(): void ); } + #[Given('I am in a PHP project that has an outdated extension')] + public function iAmInAPHPProjectThatHasAnOutdatedExtensions(): void + { + // Ensure we have an older example ext, but redis is up to date + $this->runPieCommand(['install', 'asgrim/example-pie-extension:^1.0', 'phpredis/phpredis']); + $this->assertCommandSuccessful(); + + $this->runPieCommand(['show']); + $this->assertCommandSuccessful(); + + // should've been updated from ^1.0 to ^2.0 + self::assertPackageVersionInstalledInPieShowOutput( + $this->output, + 'asgrim/example-pie-extension', + '^1.0', + ); + + $this->workingDirectory = realpath(__DIR__ . '/../assets/example-php-project'); + } + #[Given('I am in a PHP project that has missing extensions')] public function iAmInAPHPProjectThatHasMissingExtensions(): void { @@ -527,6 +547,23 @@ public function iShouldSeeAllTheExtensionsAreNowInstalled(): void self::assertPackageVersionInstalledInPieShowOutput($pieShowOutput, 'phpredis/phpredis'); } + #[Then('I should see the outdated extension is updated')] + public function iShouldSeeTheOutdatedExtensionsIsUpdated(): void + { + $this->workingDirectory = null; + $this->assertCommandSuccessful(); + + $this->runPieCommand(['show']); + $this->assertCommandSuccessful(); + + // should've been updated from ^1.0 to ^2.0 + self::assertPackageVersionInstalledInPieShowOutput( + $this->output, + 'asgrim/example-pie-extension', + '^2.0', + ); + } + #[Then('I should see information on how to select packages for install')] public function iShouldSeeInformationOnHowToSelectPackagesForInstall(): void { From 033011388a9be2d4328b14e720bbc9880a006599 Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Wed, 14 Oct 2026 21:46:45 +0100 Subject: [PATCH 2/8] 775: model non-PIE outdated extension too, with non-upgrade outcome --- features/install-in-php-project.feature | 10 +++- test/behaviour/CliContext.php | 77 ++++++++++++++++++++++++- 2 files changed, 83 insertions(+), 4 deletions(-) diff --git a/features/install-in-php-project.feature b/features/install-in-php-project.feature index ac311951..0f314af8 100644 --- a/features/install-in-php-project.feature +++ b/features/install-in-php-project.feature @@ -13,7 +13,13 @@ Feature: Extensions for a PHP project can be installed with PIE Then I should see information on how to select packages for install # pie install - Example: PIE running in a PHP project with out of date extensions - Given I am in a PHP project that has an outdated extension + Example: PIE running in a PHP project with out of date extensions installed by PIE + Given I am in a PHP project that has an outdated extension installed with PIE When I run a command to install the extensions with package selections Then I should see the outdated extension is updated + + # pie install + Example: PIE running in a PHP project with out of date extensions installed externally + Given I am in a PHP project that has an outdated extension installed externally + When I run a command to install the extensions with package selections + Then I should see the outdated extension has not been updated diff --git a/test/behaviour/CliContext.php b/test/behaviour/CliContext.php index 6f2c4dd3..bd396bdf 100644 --- a/test/behaviour/CliContext.php +++ b/test/behaviour/CliContext.php @@ -12,6 +12,7 @@ use Behat\Step\When; use Composer\Semver\VersionParser; use Composer\Util\Platform; +use Php\Pie\Util\Emoji; use Php\PieIntegrationTest\ExamplePieExtensionFixture; use RuntimeException; use Safe\Exceptions\PcreException; @@ -57,6 +58,10 @@ class CliContext implements Context private string $pieLockContentBackup; /** @var non-empty-string|null */ private string|null $developmentBranchRepositoryPath = null; + /** @var list */ + private array $externalExtensionPaths = []; + private string|null $phpIniPath = null; + private string|null $phpIniContentBackup = null; /** @throws PcreException */ #[AfterScenario] @@ -489,8 +494,8 @@ public function theExtensionFailsToInstallDueToTheMissingLibrary(): void ); } - #[Given('I am in a PHP project that has an outdated extension')] - public function iAmInAPHPProjectThatHasAnOutdatedExtensions(): void + #[Given('I am in a PHP project that has an outdated extension installed with PIE')] + public function iAmInAPHPProjectThatHasAnOutdatedExtensionsInstalledWithPie(): void { // Ensure we have an older example ext, but redis is up to date $this->runPieCommand(['install', 'asgrim/example-pie-extension:^1.0', 'phpredis/phpredis']); @@ -509,6 +514,56 @@ public function iAmInAPHPProjectThatHasAnOutdatedExtensions(): void $this->workingDirectory = realpath(__DIR__ . '/../assets/example-php-project'); } + #[Given('I am in a PHP project that has an outdated extension installed externally')] + public function iAmInAPHPProjectThatHasAnOutdatedExtensionInstalledExternally(): void + { + $this->runPieCommand(['install', 'phpredis/phpredis']); + $this->assertCommandSuccessful(); + + $sourcePath = sys_get_temp_dir() . '/pie-external-ext-' . uniqid('', true); + $this->externalExtensionPaths = [$sourcePath]; + (new Process(['git', 'clone', '--quiet', '--branch', '1.0.1', '/example-pie-extension', $sourcePath]))->mustRun(); + (new Process(['phpize'], $sourcePath))->mustRun(); + (new Process(['./configure'], $sourcePath, timeout: 120))->mustRun(); + (new Process(['make'], $sourcePath, timeout: 120))->mustRun(); + (new Process(['make', 'install'], $sourcePath))->mustRun(); + + $extensionDir = trim((new Process([self::PHP_BINARY, '-r', 'echo ini_get("extension_dir");']))->mustRun()->getOutput()); + $this->externalExtensionPaths[] = $extensionDir . '/example_pie_extension.so'; + + $phpIniPath = trim((new Process([self::PHP_BINARY, '-r', 'echo php_ini_loaded_file();']))->mustRun()->getOutput()); + $this->phpIniPath = $phpIniPath; + $this->phpIniContentBackup = file_get_contents($phpIniPath); + file_put_contents($phpIniPath, $this->phpIniContentBackup . "\nextension=example_pie_extension\n"); + + Assert::same( + trim((new Process([self::PHP_BINARY, '-r', 'echo phpversion("example_pie_extension");']))->mustRun()->getOutput()), + '0.1.0', + ); + + $this->runPieCommand(['show']); + $this->assertCommandSuccessful(); + Assert::notContains($this->output, 'asgrim/example-pie-extension'); + + $this->workingDirectory = realpath(__DIR__ . '/../assets/example-php-project'); + } + + #[AfterScenario] + public function removeExternallyInstalledExtension(): void + { + if ($this->phpIniPath !== null && $this->phpIniContentBackup !== null) { + file_put_contents($this->phpIniPath, $this->phpIniContentBackup); + } + + if ($this->externalExtensionPaths !== []) { + (new Process(['rm', '-rf', ...$this->externalExtensionPaths]))->mustRun(); + } + + $this->phpIniPath = null; + $this->phpIniContentBackup = null; + $this->externalExtensionPaths = []; + } + #[Given('I am in a PHP project that has missing extensions')] public function iAmInAPHPProjectThatHasMissingExtensions(): void { @@ -564,6 +619,24 @@ public function iShouldSeeTheOutdatedExtensionsIsUpdated(): void ); } + #[Then('I should see the outdated extension has not been updated')] + public function iShouldSeeTheOutdatedExtensionHasNotBeenUpdated(): void + { + $this->workingDirectory = null; + Assert::notNull($this->output); + Assert::contains($this->output, 'ext-example_pie_extension:^2.0 ' . Emoji::WARNING . ' Version 0.1.0 is installed, but does not meet the version requirement'); + Assert::notSame($this->exitCode, 0); + + Assert::same( + trim((new Process([self::PHP_BINARY, '-r', 'echo phpversion("example_pie_extension");']))->mustRun()->getOutput()), + '0.1.0', + ); + + $this->runPieCommand(['show']); + $this->assertCommandSuccessful(); + Assert::notContains($this->output, 'asgrim/example-pie-extension'); + } + #[Then('I should see information on how to select packages for install')] public function iShouldSeeInformationOnHowToSelectPackagesForInstall(): void { From a65510d4de0062e13b511faa30d5491e32f02911 Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Thu, 15 Oct 2026 12:14:16 +0100 Subject: [PATCH 3/8] 775: updated CheckExtensionStatus to fall back to raw extension version where PIE package does not exists --- .../InstallExtensionsForProjectCommand.php | 20 ++++- .../CheckExtensionStatus.php | 36 ++++----- .../ExtensionInstalledStatus.php | 12 +++ test/behaviour/CliContext.php | 2 + ...InstallExtensionsForProjectCommandTest.php | 15 ++-- .../CheckExtensionStatusTest.php | 74 +++++++++++++++---- 6 files changed, 114 insertions(+), 45 deletions(-) create mode 100644 src/Installing/InstallForPhpProject/ExtensionInstalledStatus.php diff --git a/src/Command/InstallExtensionsForProjectCommand.php b/src/Command/InstallExtensionsForProjectCommand.php index 6c2bf9ad..7b2eecbc 100644 --- a/src/Command/InstallExtensionsForProjectCommand.php +++ b/src/Command/InstallExtensionsForProjectCommand.php @@ -18,6 +18,7 @@ use Php\Pie\Installing\InstallForPhpProject\CheckExtensionStatus; use Php\Pie\Installing\InstallForPhpProject\ComposerFactoryForProject; use Php\Pie\Installing\InstallForPhpProject\DetermineExtensionsRequired; +use Php\Pie\Installing\InstallForPhpProject\ExtensionInstalledStatus; use Php\Pie\Installing\InstallForPhpProject\InstallPiePackageFromPath; use Php\Pie\Installing\InstallForPhpProject\InstallSelectedPackage; use Php\Pie\Installing\InstallForPhpProject\NoMatchingPackagesFound; @@ -37,8 +38,8 @@ use Throwable; use Webmozart\Assert\Assert; +use function array_change_key_case; use function array_filter; -use function array_keys; use function array_map; use function array_values; use function count; @@ -138,7 +139,7 @@ private function handlePhpProject(InputInterface $input, RootPackageInterface $r ), ); - $phpEnabledExtensions = array_map('strtolower', array_keys($targetPlatform->phpBinaryPath->extensions())); + $phpEnabledExtensions = array_change_key_case($targetPlatform->phpBinaryPath->extensions()); $installedPiePackages = $this->installedPiePackages->allPiePackages($pieComposer); $anyErrorsHappened = false; @@ -205,7 +206,7 @@ function (Link $link) use ($pieComposer, $phpEnabledExtensions, $installedPiePac * Returns false if any error happened whilst trying to install the extension; returns true if was already * installed, or it was successfully installed if needed. * - * @param list $phpEnabledExtensions + * @param array $phpEnabledExtensions * @param array $extensionToPackageSelections */ private function handleSingleExtensionRequiredByPhpProject( @@ -222,10 +223,21 @@ private function handleSingleExtensionRequiredByPhpProject( ->onlyVerifiedFor($targetPlatform); // Check if the extension is already installed, if it is, return early. - if (($this->checkExtensionStatus)($link, $piePackagesForExtension, $phpEnabledExtensions)) { + $installedStatus = ($this->checkExtensionStatus)($link, $piePackagesForExtension, $phpEnabledExtensions); + if ($installedStatus === ExtensionInstalledStatus::Installed) { return true; } + // It's not a PIE-enabled extension + if ($installedStatus === ExtensionInstalledStatus::ConstraintNotMet && ! $piePackagesForExtension->count()) { + $this->io->writeError(sprintf( + '%s didn\'t match the required version constraints, but was not installed by PIE, so PIE will not update this extension automatically.', + $extension->nameWithExtPrefix(), + )); + + return false; + } + try { $requestedPackageAndVersion = ($this->selectPackageForExtension)( $extension, diff --git a/src/Installing/InstallForPhpProject/CheckExtensionStatus.php b/src/Installing/InstallForPhpProject/CheckExtensionStatus.php index 0494b32f..913c6391 100644 --- a/src/Installing/InstallForPhpProject/CheckExtensionStatus.php +++ b/src/Installing/InstallForPhpProject/CheckExtensionStatus.php @@ -11,8 +11,8 @@ use Php\Pie\Platform\PiePackageList; use Php\Pie\Util\Emoji; +use function array_key_exists; use function count; -use function in_array; use function sprintf; use function strtolower; @@ -23,47 +23,43 @@ public function __construct(private readonly IOInterface $io) { } - /** - * Check if an extension is installed; returns true if it's installed, but outputs a warning if the version - * constraint doesn't match. Returns true of the extension is missing. - * - * @param list $phpEnabledExtensions - */ + /** @param array $phpEnabledExtensions */ public function __invoke( Link $link, PiePackageList $piePackagesForExtension, array $phpEnabledExtensions, - ): bool { - $extension = ExtensionName::normaliseFromString($link->getTarget()); - $linkRequiresConstraint = $link->getPrettyConstraint(); + ): ExtensionInstalledStatus { + $extension = ExtensionName::normaliseFromString($link->getTarget()); + $lowercasedExtensionName = strtolower($extension->name()); + $linkRequiresConstraint = $link->getPrettyConstraint(); - $piePackageVersion = null; + $installedPieOrExtensionVersion = $phpEnabledExtensions[$lowercasedExtensionName] ?? null; if (count($piePackagesForExtension) === 1) { - $piePackageVersion = $piePackagesForExtension->onlyOne()->version(); + $installedPieOrExtensionVersion = $piePackagesForExtension->onlyOne()->version(); } $piePackageVersionMatchesLinkConstraint = null; - if ($piePackageVersion !== null) { + if ($installedPieOrExtensionVersion !== null) { $piePackageVersionMatchesLinkConstraint = $link ->getConstraint() ->matches( - (new VersionParser())->parseConstraints($piePackageVersion), + (new VersionParser())->parseConstraints($installedPieOrExtensionVersion), ); } - if (in_array(strtolower($extension->name()), $phpEnabledExtensions)) { - if ($piePackageVersion !== null && $piePackageVersionMatchesLinkConstraint === false) { + if (array_key_exists($lowercasedExtensionName, $phpEnabledExtensions)) { + if ($installedPieOrExtensionVersion !== null && $piePackageVersionMatchesLinkConstraint === false) { $this->io->write(sprintf( '%s: %s:%s %s Version %s is installed, but does not meet the version requirement', $link->getDescription(), $extension->nameWithExtPrefix(), $linkRequiresConstraint, Emoji::WARNING, - $piePackageVersion, + $installedPieOrExtensionVersion, )); - return true; + return ExtensionInstalledStatus::ConstraintNotMet; } $this->io->write(sprintf( @@ -74,7 +70,7 @@ public function __invoke( Emoji::GREEN_CHECKMARK, )); - return true; + return ExtensionInstalledStatus::Installed; } $this->io->write(sprintf( @@ -85,6 +81,6 @@ public function __invoke( Emoji::PROHIBITED, )); - return false; + return ExtensionInstalledStatus::Missing; } } diff --git a/src/Installing/InstallForPhpProject/ExtensionInstalledStatus.php b/src/Installing/InstallForPhpProject/ExtensionInstalledStatus.php new file mode 100644 index 00000000..2563726a --- /dev/null +++ b/src/Installing/InstallForPhpProject/ExtensionInstalledStatus.php @@ -0,0 +1,12 @@ +workingDirectory = null; Assert::notNull($this->output); + Assert::notNull($this->errorOutput); Assert::contains($this->output, 'ext-example_pie_extension:^2.0 ' . Emoji::WARNING . ' Version 0.1.0 is installed, but does not meet the version requirement'); + Assert::contains($this->errorOutput, 'ext-example_pie_extension didn\'t match the required version constraints, but was not installed by PIE, so PIE will not update this extension automatically.'); Assert::notSame($this->exitCode, 0); Assert::same( diff --git a/test/integration/Command/InstallExtensionsForProjectCommandTest.php b/test/integration/Command/InstallExtensionsForProjectCommandTest.php index 53000176..b814dff6 100644 --- a/test/integration/Command/InstallExtensionsForProjectCommandTest.php +++ b/test/integration/Command/InstallExtensionsForProjectCommandTest.php @@ -11,6 +11,7 @@ use Composer\Repository\InstalledArrayRepository; use Composer\Repository\RepositoryManager; use Composer\Semver\Constraint\Constraint; +use Composer\Semver\Constraint\MatchAllConstraint; use Composer\Semver\Constraint\MultiConstraint; use Php\Pie\Command\InstallExtensionsForProjectCommand; use Php\Pie\ComposerIntegration\MinimalHelperSet; @@ -101,8 +102,8 @@ public function testInstallingExtensionsForPhpProject(): void { $rootPackage = new RootPackage('my/project', '1.2.3.0', '1.2.3'); $rootPackage->setRequires([ - 'ext-standard' => new Link('my/project', 'ext-standard', new Constraint('=', '*'), Link::TYPE_REQUIRE, '*'), - 'ext-spl' => new Link('my/project', 'ext-spl', new Constraint('=', '*'), Link::TYPE_REQUIRE, '*'), + 'ext-standard' => new Link('my/project', 'ext-standard', new MatchAllConstraint(), Link::TYPE_REQUIRE, '*'), + 'ext-spl' => new Link('my/project', 'ext-spl', new MatchAllConstraint(), Link::TYPE_REQUIRE, '*'), 'ext-foobar' => new Link('my/project', 'ext-foobar', new MultiConstraint([ new Constraint('>=', '1.2.0.0-dev'), new Constraint('<', '2.0.0.0-dev'), @@ -161,10 +162,10 @@ public function testInstallingExtensionsForPhpProjectIncludesDevRequiresByDefaul { $rootPackage = new RootPackage('my/project', '1.2.3.0', '1.2.3'); $rootPackage->setRequires([ - 'ext-standard' => new Link('my/project', 'ext-standard', new Constraint('=', '*'), Link::TYPE_REQUIRE, '*'), + 'ext-standard' => new Link('my/project', 'ext-standard', new MatchAllConstraint(), Link::TYPE_REQUIRE, '*'), ]); $rootPackage->setDevRequires([ - 'ext-foobar' => new Link('my/project', 'ext-foobar', new Constraint('=', '*'), Link::TYPE_DEV_REQUIRE, '*'), + 'ext-foobar' => new Link('my/project', 'ext-foobar', new MatchAllConstraint(), Link::TYPE_DEV_REQUIRE, '*'), ]); $this->composerFactoryForProject->method('rootPackage')->willReturn($rootPackage); @@ -197,10 +198,10 @@ public function testInstallingExtensionsForPhpProjectExcludesDevRequiresWhenNoDe { $rootPackage = new RootPackage('my/project', '1.2.3.0', '1.2.3'); $rootPackage->setRequires([ - 'ext-standard' => new Link('my/project', 'ext-standard', new Constraint('=', '*'), Link::TYPE_REQUIRE, '*'), + 'ext-standard' => new Link('my/project', 'ext-standard', new MatchAllConstraint(), Link::TYPE_REQUIRE, '*'), ]); $rootPackage->setDevRequires([ - 'ext-foobar' => new Link('my/project', 'ext-foobar', new Constraint('=', '*'), Link::TYPE_DEV_REQUIRE, '*'), + 'ext-foobar' => new Link('my/project', 'ext-foobar', new MatchAllConstraint(), Link::TYPE_DEV_REQUIRE, '*'), ]); $this->composerFactoryForProject->method('rootPackage')->willReturn($rootPackage); @@ -234,7 +235,7 @@ public function testInstallingExtensionsForPhpProjectWithMultipleMatches(): void { $rootPackage = new RootPackage('my/project', '1.2.3.0', '1.2.3'); $rootPackage->setRequires([ - 'ext-standard' => new Link('my/project', 'ext-standard', new Constraint('=', '*'), Link::TYPE_REQUIRE, '*'), + 'ext-standard' => new Link('my/project', 'ext-standard', new MatchAllConstraint(), Link::TYPE_REQUIRE, '*'), 'ext-foobar' => new Link('my/project', 'ext-foobar', new MultiConstraint([ new Constraint('>=', '1.2.0.0-dev'), new Constraint('<', '2.0.0.0-dev'), diff --git a/test/unit/Installing/InstallForPhpProject/CheckExtensionStatusTest.php b/test/unit/Installing/InstallForPhpProject/CheckExtensionStatusTest.php index 0aef1b83..54d7124d 100644 --- a/test/unit/Installing/InstallForPhpProject/CheckExtensionStatusTest.php +++ b/test/unit/Installing/InstallForPhpProject/CheckExtensionStatusTest.php @@ -13,6 +13,7 @@ use Php\Pie\ExtensionName; use Php\Pie\ExtensionType; use Php\Pie\Installing\InstallForPhpProject\CheckExtensionStatus; +use Php\Pie\Installing\InstallForPhpProject\ExtensionInstalledStatus; use Php\Pie\Platform\PiePackageList; use Php\Pie\Util\Emoji; use PHPUnit\Framework\Attributes\CoversClass; @@ -47,51 +48,96 @@ private function makePackage(string $version): Package ); } - public function testAlreadyInstalledWithNoVersionInfo(): void + public function testPiePackageAlreadyInstalledWithMatchingVersion(): void { - $link = new Link('my/project', 'ext-foobar', new MatchAllConstraint(), Link::TYPE_REQUIRE, '*'); + $link = new Link('my/project', 'ext-foobar', (new VersionParser())->parseConstraints('^1.0'), Link::TYPE_REQUIRE, '^1.0'); $result = ($this->checkExtensionStatus)( $link, - new PiePackageList([]), - ['foobar'], + new PiePackageList([$this->makePackage('1.2.0')]), + ['foobar' => '0'], ); - self::assertTrue($result); + self::assertSame(ExtensionInstalledStatus::Installed, $result); self::assertStringContainsString('Already installed', $this->io->getOutput()); self::assertStringContainsString(Emoji::GREEN_CHECKMARK, $this->io->getOutput()); } - public function testAlreadyInstalledWithMatchingVersion(): void + public function testPiePackageVersionMismatchWarning(): void { - $link = new Link('my/project', 'ext-foobar', (new VersionParser())->parseConstraints('^1.0'), Link::TYPE_REQUIRE, '^1.0'); + $link = new Link('my/project', 'ext-foobar', (new VersionParser())->parseConstraints('^2.0'), Link::TYPE_REQUIRE, '^2.0'); $result = ($this->checkExtensionStatus)( $link, new PiePackageList([$this->makePackage('1.2.0')]), - ['foobar'], + ['foobar' => '0'], + ); + + self::assertSame(ExtensionInstalledStatus::ConstraintNotMet, $result); + self::assertStringContainsString('Version 1.2.0 is installed, but does not meet the version requirement', $this->io->getOutput()); + self::assertStringContainsString(Emoji::WARNING, $this->io->getOutput()); + } + + public function testNonPieExtensionPresentAndMatchesConstraint(): void + { + $link = new Link('my/project', 'ext-foobar', (new VersionParser())->parseConstraints('^2.0'), Link::TYPE_REQUIRE, '^2.0'); + + $result = ($this->checkExtensionStatus)( + $link, + new PiePackageList([]), + ['foobar' => '2.0.5'], ); - self::assertTrue($result); + self::assertSame(ExtensionInstalledStatus::Installed, $result); self::assertStringContainsString('Already installed', $this->io->getOutput()); self::assertStringContainsString(Emoji::GREEN_CHECKMARK, $this->io->getOutput()); } - public function testVersionMismatchWarning(): void + public function testNonPieExtensionPresentButVersionMismatch(): void { $link = new Link('my/project', 'ext-foobar', (new VersionParser())->parseConstraints('^2.0'), Link::TYPE_REQUIRE, '^2.0'); $result = ($this->checkExtensionStatus)( $link, - new PiePackageList([$this->makePackage('1.2.0')]), - ['foobar'], + new PiePackageList([]), + ['foobar' => '1.2.0'], ); - self::assertTrue($result); + self::assertSame(ExtensionInstalledStatus::ConstraintNotMet, $result); self::assertStringContainsString('Version 1.2.0 is installed, but does not meet the version requirement', $this->io->getOutput()); self::assertStringContainsString(Emoji::WARNING, $this->io->getOutput()); } + public function testNonPieExtensionPresentWithNoVersionInfo(): void + { + $link = new Link('my/project', 'ext-foobar', (new VersionParser())->parseConstraints('^2.0'), Link::TYPE_REQUIRE, '^2.0'); + + $result = ($this->checkExtensionStatus)( + $link, + new PiePackageList([]), + ['foobar' => '0'], + ); + + self::assertSame(ExtensionInstalledStatus::ConstraintNotMet, $result); + self::assertStringContainsString('Version 0 is installed, but does not meet the version requirement', $this->io->getOutput()); + self::assertStringContainsString(Emoji::WARNING, $this->io->getOutput()); + } + + public function testNonPieExtensionPresentWithNoVersionInfoButMatchesAll(): void + { + $link = new Link('my/project', 'ext-foobar', new MatchAllConstraint(), Link::TYPE_REQUIRE, '*'); + + $result = ($this->checkExtensionStatus)( + $link, + new PiePackageList([]), + ['foobar' => '0'], + ); + + self::assertSame(ExtensionInstalledStatus::Installed, $result); + self::assertStringContainsString('Already installed', $this->io->getOutput()); + self::assertStringContainsString(Emoji::GREEN_CHECKMARK, $this->io->getOutput()); + } + public function testMissingExtension(): void { $link = new Link('my/project', 'ext-foobar', new MatchAllConstraint(), Link::TYPE_REQUIRE, '*'); @@ -102,7 +148,7 @@ public function testMissingExtension(): void [], ); - self::assertFalse($result); + self::assertSame(ExtensionInstalledStatus::Missing, $result); self::assertStringContainsString('Missing', $this->io->getOutput()); self::assertStringContainsString(Emoji::PROHIBITED, $this->io->getOutput()); } From 4bc6a2c32e5d6b8ad38a8f706458f9b5c326d90f Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Thu, 15 Oct 2026 13:21:57 +0100 Subject: [PATCH 4/8] 775: extract extension version normalising that PhpBinaryPathBasedPlatformRepository uses for extension versions --- .../PhpBinaryPathBasedPlatformRepository.php | 23 ++------ src/ExtensionVersion.php | 45 ++++++++++++++++ test/unit/ExtensionVersionTest.php | 53 +++++++++++++++++++ 3 files changed, 103 insertions(+), 18 deletions(-) create mode 100644 src/ExtensionVersion.php create mode 100644 test/unit/ExtensionVersionTest.php diff --git a/src/ComposerIntegration/PhpBinaryPathBasedPlatformRepository.php b/src/ComposerIntegration/PhpBinaryPathBasedPlatformRepository.php index f65121b2..b1a7bccf 100644 --- a/src/ComposerIntegration/PhpBinaryPathBasedPlatformRepository.php +++ b/src/ComposerIntegration/PhpBinaryPathBasedPlatformRepository.php @@ -7,10 +7,10 @@ use Composer\Composer; use Composer\Package\CompletePackage; use Composer\Package\CompletePackageInterface; -use Composer\Pcre\Preg; use Composer\Repository\PlatformRepository; use Composer\Semver\VersionParser; use Php\Pie\ExtensionName; +use Php\Pie\ExtensionVersion; use Php\Pie\Platform\InstalledPiePackages; use Php\Pie\Platform\PkgConfig; use Php\Pie\Platform\TargetPhp\PhpBinaryPath; @@ -134,27 +134,14 @@ public static function forTargetPlatform(TargetPlatform $targetPlatform, Compose private function packageForExtension(string $name, string $prettyVersion): CompletePackageInterface { - $extraDescription = ''; - - try { - $version = $this->versionParser->normalize($prettyVersion); - } catch (UnexpectedValueException) { - $extraDescription = ' (actual version: ' . $prettyVersion . ')'; - if (Preg::isMatchStrictGroups('{^(\d+\.\d+\.\d+(?:\.\d+)?)}', $prettyVersion, $match)) { - $prettyVersion = $match[1]; - } else { - $prettyVersion = '0'; - } - - $version = $this->versionParser->normalize($prettyVersion); - } + $extVersion = ExtensionVersion::parse($prettyVersion); $package = new CompletePackage( 'ext-' . str_replace(' ', '-', strtolower($name)), - $version, - $prettyVersion, + $extVersion->normalised, + $extVersion->originalOrModified, ); - $package->setDescription('The ' . $name . ' PHP extension' . $extraDescription); + $package->setDescription('The ' . $name . ' PHP extension' . ($extVersion->wasModified() ? ' (actual version: ' . $prettyVersion . ')' : '')); $package->setType('php-ext'); return $package; diff --git a/src/ExtensionVersion.php b/src/ExtensionVersion.php new file mode 100644 index 00000000..8e06481d --- /dev/null +++ b/src/ExtensionVersion.php @@ -0,0 +1,45 @@ +normalize($original); + } catch (UnexpectedValueException) { + if (Preg::isMatchStrictGroups('{^(\d+\.\d+\.\d+(?:\.\d+)?)}', $original, $match)) { + $originalOrModified = $match[1]; + } else { + $originalOrModified = '0'; + } + + $normalised = $versionParser->normalize($originalOrModified); + } + + return new self($original, $originalOrModified, $normalised); + } + + public function wasModified(): bool + { + return $this->original !== $this->originalOrModified; + } +} diff --git a/test/unit/ExtensionVersionTest.php b/test/unit/ExtensionVersionTest.php new file mode 100644 index 00000000..1d7899ce --- /dev/null +++ b/test/unit/ExtensionVersionTest.php @@ -0,0 +1,53 @@ + */ + public static function versionParsingProvider(): array + { + return [ + // Composer's normalizer worked without issue + ['1.2.3', '1.2.3', '1.2.3.0', false], + ['1.2.3.4', '1.2.3.4', '1.2.3.4', false], + ['1.2', '1.2', '1.2.0.0', false], + ['1', '1', '1.0.0.0', false], + ['0', '0', '0.0.0.0', false], + ['v1.2.3', 'v1.2.3', '1.2.3.0', false], + ['2.1.0RC1', '2.1.0RC1', '2.1.0.0-RC1', false], + ['2.0.0beta', '2.0.0beta', '2.0.0.0-beta', false], + ['1.13.0-dev', '1.13.0-dev', '1.13.0.0-dev', false], + ['3.4.0alpha2-dev', '3.4.0alpha2-dev', '3.4.0.0-alpha2-dev', false], + ['1.2.3+abc', '1.2.3+abc', '1.2.3.0', false], + ['1.2.3-alpha.34', '1.2.3-alpha.34', '1.2.3.0-alpha34', false], + ['dev-main', 'dev-main', 'dev-main', false], + ['20261015', '20261015', '20261015', false], + // Fallback, best-guess parsers + ['some-text', '0', '0.0.0.0', true], + ['1.2.3-abcdef', '1.2.3', '1.2.3.0', true], + ['8.3.6-1ubuntu1', '8.3.6', '8.3.6.0', true], + ['7.4.33-nmm8', '7.4.33', '7.4.33.0', true], + ['1.2-abc', '0', '0.0.0.0', true], + ]; + } + + #[DataProvider('versionParsingProvider')] + public function testParse(string $input, string $expectedOriginalOrModified, string $expectedNormalised, bool $expectedWasModified): void + { + $parsed = ExtensionVersion::parse($input); + + self::assertSame($input, $parsed->original); + self::assertSame($expectedOriginalOrModified, $parsed->originalOrModified); + self::assertSame($expectedNormalised, $parsed->normalised); + self::assertSame($expectedWasModified, $parsed->wasModified()); + } +} From 2aa68022d637a47fad867ffd4f784552b9ed0911 Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Thu, 15 Oct 2026 13:35:06 +0100 Subject: [PATCH 5/8] 775: use ExtensionVersion to allow normalised extension version processing to be permissive --- .../InstallForPhpProject/CheckExtensionStatus.php | 3 ++- .../CheckExtensionStatusTest.php | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/Installing/InstallForPhpProject/CheckExtensionStatus.php b/src/Installing/InstallForPhpProject/CheckExtensionStatus.php index 913c6391..47541721 100644 --- a/src/Installing/InstallForPhpProject/CheckExtensionStatus.php +++ b/src/Installing/InstallForPhpProject/CheckExtensionStatus.php @@ -8,6 +8,7 @@ use Composer\Package\Link; use Composer\Package\Version\VersionParser; use Php\Pie\ExtensionName; +use Php\Pie\ExtensionVersion; use Php\Pie\Platform\PiePackageList; use Php\Pie\Util\Emoji; @@ -44,7 +45,7 @@ public function __invoke( $piePackageVersionMatchesLinkConstraint = $link ->getConstraint() ->matches( - (new VersionParser())->parseConstraints($installedPieOrExtensionVersion), + (new VersionParser())->parseConstraints(ExtensionVersion::parse($installedPieOrExtensionVersion)->normalised), ); } diff --git a/test/unit/Installing/InstallForPhpProject/CheckExtensionStatusTest.php b/test/unit/Installing/InstallForPhpProject/CheckExtensionStatusTest.php index 54d7124d..93d9f7ea 100644 --- a/test/unit/Installing/InstallForPhpProject/CheckExtensionStatusTest.php +++ b/test/unit/Installing/InstallForPhpProject/CheckExtensionStatusTest.php @@ -108,6 +108,21 @@ public function testNonPieExtensionPresentButVersionMismatch(): void self::assertStringContainsString(Emoji::WARNING, $this->io->getOutput()); } + public function testNonPieExtensionPresentWithUnparseableVersion(): void + { + $link = new Link('my/project', 'ext-foobar', (new VersionParser())->parseConstraints('^2.0'), Link::TYPE_REQUIRE, '^2.0'); + + $result = ($this->checkExtensionStatus)( + $link, + new PiePackageList([]), + ['foobar' => '1.2.3-abcdef'], + ); + + self::assertSame(ExtensionInstalledStatus::ConstraintNotMet, $result); + self::assertStringContainsString('Version 1.2.3-abcdef is installed, but does not meet the version requirement', $this->io->getOutput()); + self::assertStringContainsString(Emoji::WARNING, $this->io->getOutput()); + } + public function testNonPieExtensionPresentWithNoVersionInfo(): void { $link = new Link('my/project', 'ext-foobar', (new VersionParser())->parseConstraints('^2.0'), Link::TYPE_REQUIRE, '^2.0'); From 103642269e4089a4f1d50ace4f0f3688440a86e3 Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Thu, 15 Oct 2026 13:51:20 +0100 Subject: [PATCH 6/8] 775: add test cases for bundled extensions; same behaviour but just explicit about how they're handled now --- .../CheckExtensionStatusTest.php | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/test/unit/Installing/InstallForPhpProject/CheckExtensionStatusTest.php b/test/unit/Installing/InstallForPhpProject/CheckExtensionStatusTest.php index 93d9f7ea..766aae62 100644 --- a/test/unit/Installing/InstallForPhpProject/CheckExtensionStatusTest.php +++ b/test/unit/Installing/InstallForPhpProject/CheckExtensionStatusTest.php @@ -167,4 +167,57 @@ public function testMissingExtension(): void self::assertStringContainsString('Missing', $this->io->getOutput()); self::assertStringContainsString(Emoji::PROHIBITED, $this->io->getOutput()); } + + public function testBundledExtensionPresentAndUnconstrained(): void + { + $link = new Link('my/project', 'ext-json', new MatchAllConstraint(), Link::TYPE_REQUIRE, '*'); + + $result = ($this->checkExtensionStatus)( + $link, + new PiePackageList([]), + ['json' => '8.5.11'], + ); + + self::assertSame(ExtensionInstalledStatus::Installed, $result); + } + + public function testBundledExtensionPresentAndMatchesConstraints(): void + { + $link = new Link('my/project', 'ext-json', (new VersionParser())->parseConstraints('8.5.*'), Link::TYPE_REQUIRE, '8.5.*'); + + $result = ($this->checkExtensionStatus)( + $link, + new PiePackageList([]), + ['json' => '8.5.11'], + ); + + self::assertSame(ExtensionInstalledStatus::Installed, $result); + } + + public function testBundledExtensionPresentButDoesNotMatchConstraints(): void + { + $link = new Link('my/project', 'ext-json', (new VersionParser())->parseConstraints('8.5.*'), Link::TYPE_REQUIRE, '8.5.*'); + + $result = ($this->checkExtensionStatus)( + $link, + new PiePackageList([]), + ['json' => '8.4.26'], + ); + + self::assertSame(ExtensionInstalledStatus::ConstraintNotMet, $result); + self::assertStringContainsString('Version 8.4.26 is installed, but does not meet the version requirement', $this->io->getOutput()); + } + + public function testBundledExtensionNotPresentButIsRequired(): void + { + $link = new Link('my/project', 'ext-json', new MatchAllConstraint(), Link::TYPE_REQUIRE, '*'); + + $result = ($this->checkExtensionStatus)( + $link, + new PiePackageList([]), + [], + ); + + self::assertSame(ExtensionInstalledStatus::Missing, $result); + } } From c4187ea58193da998e9ebbf1922ea994f6f73c76 Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Thu, 15 Oct 2026 15:30:37 +0100 Subject: [PATCH 7/8] 775: clean up php.ini properly --- test/behaviour/CliContext.php | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/test/behaviour/CliContext.php b/test/behaviour/CliContext.php index 2007376b..dd3a4ff1 100644 --- a/test/behaviour/CliContext.php +++ b/test/behaviour/CliContext.php @@ -517,6 +517,10 @@ public function iAmInAPHPProjectThatHasAnOutdatedExtensionsInstalledWithPie(): v #[Given('I am in a PHP project that has an outdated extension installed externally')] public function iAmInAPHPProjectThatHasAnOutdatedExtensionInstalledExternally(): void { + $phpIniPath = trim((new Process([self::PHP_BINARY, '-r', 'echo php_ini_loaded_file();']))->mustRun()->getOutput()); + $this->phpIniPath = $phpIniPath; + $this->phpIniContentBackup = file_get_contents($phpIniPath); + $this->runPieCommand(['install', 'phpredis/phpredis']); $this->assertCommandSuccessful(); @@ -531,10 +535,7 @@ public function iAmInAPHPProjectThatHasAnOutdatedExtensionInstalledExternally(): $extensionDir = trim((new Process([self::PHP_BINARY, '-r', 'echo ini_get("extension_dir");']))->mustRun()->getOutput()); $this->externalExtensionPaths[] = $extensionDir . '/example_pie_extension.so'; - $phpIniPath = trim((new Process([self::PHP_BINARY, '-r', 'echo php_ini_loaded_file();']))->mustRun()->getOutput()); - $this->phpIniPath = $phpIniPath; - $this->phpIniContentBackup = file_get_contents($phpIniPath); - file_put_contents($phpIniPath, $this->phpIniContentBackup . "\nextension=example_pie_extension\n"); + file_put_contents($phpIniPath, file_get_contents($phpIniPath) . "\nextension=example_pie_extension\n"); Assert::same( trim((new Process([self::PHP_BINARY, '-r', 'echo phpversion("example_pie_extension");']))->mustRun()->getOutput()), From 8c9236da2925cea8736315d82f1a9bd6ab8311d9 Mon Sep 17 00:00:00 2001 From: James Titcumb Date: Thu, 15 Oct 2026 15:46:59 +0100 Subject: [PATCH 8/8] 775: ensure new test works on a wider PHP version spread --- test/assets/example-php-project/composer.json | 2 +- test/assets/example-php-project/composer.lock | 4 ++-- test/behaviour/CliContext.php | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/test/assets/example-php-project/composer.json b/test/assets/example-php-project/composer.json index 82b7be0b..01f4add5 100644 --- a/test/assets/example-php-project/composer.json +++ b/test/assets/example-php-project/composer.json @@ -5,6 +5,6 @@ "require": { "php": "^8.0", "ext-redis": "*", - "ext-example_pie_extension": "^2.0" + "ext-example_pie_extension": "^2.0.9" } } diff --git a/test/assets/example-php-project/composer.lock b/test/assets/example-php-project/composer.lock index 9714929b..b597c60e 100644 --- a/test/assets/example-php-project/composer.lock +++ b/test/assets/example-php-project/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "ab8ec4f8353850311910af01c9c97085", + "content-hash": "fb0a089e8892c9d09f6f6489e22f5e57", "packages": [], "packages-dev": [], "aliases": [], @@ -15,7 +15,7 @@ "platform": { "php": "^8.0", "ext-redis": "*", - "ext-example_pie_extension": "^2.0" + "ext-example_pie_extension": "^2.0.9" }, "platform-dev": {}, "plugin-api-version": "2.9.0" diff --git a/test/behaviour/CliContext.php b/test/behaviour/CliContext.php index dd3a4ff1..e54f3136 100644 --- a/test/behaviour/CliContext.php +++ b/test/behaviour/CliContext.php @@ -498,17 +498,17 @@ public function theExtensionFailsToInstallDueToTheMissingLibrary(): void public function iAmInAPHPProjectThatHasAnOutdatedExtensionsInstalledWithPie(): void { // Ensure we have an older example ext, but redis is up to date - $this->runPieCommand(['install', 'asgrim/example-pie-extension:^1.0', 'phpredis/phpredis']); + $this->runPieCommand(['install', 'asgrim/example-pie-extension:2.0.7', 'phpredis/phpredis']); $this->assertCommandSuccessful(); $this->runPieCommand(['show']); $this->assertCommandSuccessful(); - // should've been updated from ^1.0 to ^2.0 + // should've been updated from 2.0.7 to ^2.0.9 self::assertPackageVersionInstalledInPieShowOutput( $this->output, 'asgrim/example-pie-extension', - '^1.0', + '2.0.7', ); $this->workingDirectory = realpath(__DIR__ . '/../assets/example-php-project'); @@ -612,11 +612,11 @@ public function iShouldSeeTheOutdatedExtensionsIsUpdated(): void $this->runPieCommand(['show']); $this->assertCommandSuccessful(); - // should've been updated from ^1.0 to ^2.0 + // should've been updated from 2.0.7 to ^2.0.9 self::assertPackageVersionInstalledInPieShowOutput( $this->output, 'asgrim/example-pie-extension', - '^2.0', + '^2.0.9', ); } @@ -626,7 +626,7 @@ public function iShouldSeeTheOutdatedExtensionHasNotBeenUpdated(): void $this->workingDirectory = null; Assert::notNull($this->output); Assert::notNull($this->errorOutput); - Assert::contains($this->output, 'ext-example_pie_extension:^2.0 ' . Emoji::WARNING . ' Version 0.1.0 is installed, but does not meet the version requirement'); + Assert::contains($this->output, 'ext-example_pie_extension:^2.0.9 ' . Emoji::WARNING . ' Version 0.1.0 is installed, but does not meet the version requirement'); Assert::contains($this->errorOutput, 'ext-example_pie_extension didn\'t match the required version constraints, but was not installed by PIE, so PIE will not update this extension automatically.'); Assert::notSame($this->exitCode, 0);