diff --git a/bin/pie b/bin/pie index 155b1ca4..b85fce44 100755 --- a/bin/pie +++ b/bin/pie @@ -17,6 +17,7 @@ use Php\Pie\Command\SelfUpdateCommand; use Php\Pie\Command\SelfVerifyCommand; use Php\Pie\Command\ShowCommand; use Php\Pie\Command\UninstallCommand; +use Php\Pie\Command\UpgradeCommand; use Php\Pie\Util\PieVersion; use Symfony\Component\Console\Application; use Symfony\Component\Console\CommandLoader\ContainerCommandLoader; @@ -48,6 +49,7 @@ $application->setCommandLoader(new ContainerCommandLoader( 'download' => DownloadCommand::class, 'build' => BuildCommand::class, 'install' => InstallCommand::class, + 'upgrade' => UpgradeCommand::class, 'info' => InfoCommand::class, 'show' => ShowCommand::class, 'repository:list' => RepositoryListCommand::class, diff --git a/features/upgrade-extensions.feature b/features/upgrade-extensions.feature new file mode 100644 index 00000000..db301584 --- /dev/null +++ b/features/upgrade-extensions.feature @@ -0,0 +1,13 @@ +Feature: PIE extensions can be upgraded with PIE + + # pie upgrade + Example: I can upgrade existing PIE extensions + Given I have installed PIE extensions that have upgrades available + When I run a command to upgrade my extensions + Then the extensions should have been upgraded to the latest versions + + # pie upgrade + Example: I can upgrade existing PIE extensions that had been built with configure options + Given I have installed PIE extensions with configure options that have upgrades available + When I run a command to upgrade my extensions + Then the extension has been upgraded with the previous configure options diff --git a/src/Command/UpgradeCommand.php b/src/Command/UpgradeCommand.php new file mode 100644 index 00000000..c67aee22 --- /dev/null +++ b/src/Command/UpgradeCommand.php @@ -0,0 +1,124 @@ +io->write('This command may need elevated privileges, and may prompt you for your password.'); + } + + $targetPlatform = CommandHelper::determineTargetPlatformFromInputs($input, $this->io); + + $forceInstallPackageVersion = CommandHelper::determineForceInstallingPackageVersion($input); + CommandHelper::applyNoCacheOptionIfSet($input, $this->io); + + if (CommandHelper::shouldCheckForBuildTools($input)) { + $this->checkBuildTools->check( + $this->io, + PackageManager::detect(), + $targetPlatform, + CommandHelper::autoInstallBuildTools($input), + ); + } + + if (! file_exists(PieComposerFactory::getLockFile(Platform::getPieJsonFilename($targetPlatform)))) { + $this->io->writeError('No PIE extensions are currently installed, so there is nothing to upgrade.'); + + return Command::INVALID; + } + + $existingComposer = PieComposerFactory::createPieComposer( + $this->container, + PieComposerRequest::noOperation($this->io, $targetPlatform), + ); + + $configureOptions = []; + foreach ($this->installedPiePackages->allPiePackages($existingComposer)->packages() as $installedPackage) { + $existingConfigureOptions = $installedPackage->installedJsonMetadata()->configureOptions(); + if ($existingConfigureOptions === null) { + continue; + } + + $existingConfigureOptionsList = explode(' ', $existingConfigureOptions); + Assert::allStringNotEmpty($existingConfigureOptionsList); + + $configureOptions[$installedPackage->name()] = $existingConfigureOptionsList; + } + + $composer = PieComposerFactory::createPieComposer( + $this->container, + new PieComposerRequest( + $this->io, + $targetPlatform, + [], + PieOperation::Install, + $configureOptions, + CommandHelper::determineAttemptToSetupIniFile($input), + installAllPackages: true, + ), + ); + + try { + $this->composerIntegrationHandler->runInstall( + [], + $composer, + $targetPlatform, + $forceInstallPackageVersion, + true, + ); + } catch (ComposerRunFailed $composerRunFailed) { + $this->io->writeError('' . $composerRunFailed->getMessage() . ''); + + return $composerRunFailed->getCode(); + } + + return Command::SUCCESS; + } +} diff --git a/src/ComposerIntegration/ComposerIntegrationHandler.php b/src/ComposerIntegration/ComposerIntegrationHandler.php index 26e80f91..c37f6bb6 100644 --- a/src/ComposerIntegration/ComposerIntegrationHandler.php +++ b/src/ComposerIntegration/ComposerIntegrationHandler.php @@ -197,7 +197,9 @@ public function runInstall( $localRepository->removePackage($localRepoPackage); } - $extensionNames = $installFromLock + // When no specific packages were resolved (e.g. `--from-lock`, or `upgrade`), treat every + // package tracked in the lock file as being installed/updated by this operation. + $extensionNames = $resolvedRequestedPackages === [] ? array_values(array_map(ExtensionName::determineFromComposerPackage(...), $composer->getLocker()->getLockedRepository()->getPackages())) : ResolvedPackageRequest::extensionNames($resolvedRequestedPackages); diff --git a/src/ComposerIntegration/Listeners/OverrideDownloadUrlInstallListener.php b/src/ComposerIntegration/Listeners/OverrideDownloadUrlInstallListener.php index 8ad19d12..086413f7 100644 --- a/src/ComposerIntegration/Listeners/OverrideDownloadUrlInstallListener.php +++ b/src/ComposerIntegration/Listeners/OverrideDownloadUrlInstallListener.php @@ -7,6 +7,7 @@ use Composer\Composer; use Composer\DependencyResolver\Operation\InstallOperation; use Composer\DependencyResolver\Operation\OperationInterface; +use Composer\DependencyResolver\Operation\UpdateOperation; use Composer\Installer\InstallerEvent; use Composer\Installer\InstallerEvents; use Composer\IO\IOInterface; @@ -56,11 +57,11 @@ public function __invoke(InstallerEvent $installerEvent): void array_walk( $operations, function (OperationInterface $operation): void { - if (! $operation instanceof InstallOperation) { + if (! $operation instanceof InstallOperation && ! $operation instanceof UpdateOperation) { return; } - $composerPackage = $operation->getPackage(); + $composerPackage = $operation instanceof UpdateOperation ? $operation->getTargetPackage() : $operation->getPackage(); if (! $composerPackage instanceof CompletePackageInterface) { return; } diff --git a/src/ComposerIntegration/Listeners/RemoveUnrelatedInstallOperations.php b/src/ComposerIntegration/Listeners/RemoveUnrelatedInstallOperations.php index 43850e7b..2b840237 100644 --- a/src/ComposerIntegration/Listeners/RemoveUnrelatedInstallOperations.php +++ b/src/ComposerIntegration/Listeners/RemoveUnrelatedInstallOperations.php @@ -9,6 +9,7 @@ use Composer\DependencyResolver\Operation\InstallOperation; use Composer\DependencyResolver\Operation\OperationInterface; use Composer\DependencyResolver\Operation\UninstallOperation; +use Composer\DependencyResolver\Operation\UpdateOperation; use Composer\DependencyResolver\Transaction; use Composer\Installer\InstallerEvent; use Composer\Installer\InstallerEvents; @@ -46,7 +47,7 @@ public function __invoke(InstallerEvent $installerEvent): void $newOperations = array_filter( $installerEvent->getTransaction()?->getOperations() ?? [], function (OperationInterface $operation) use ($pieOutput): bool { - if (! $operation instanceof InstallOperation && ! $operation instanceof UninstallOperation) { + if (! $operation instanceof InstallOperation && ! $operation instanceof UninstallOperation && ! $operation instanceof UpdateOperation) { $pieOutput->writeError( sprintf( 'Unexpected operation during installer: %s', @@ -58,13 +59,15 @@ function (OperationInterface $operation) use ($pieOutput): bool { return false; } - $isRequestedPiePackage = $this->composerRequest->isFor($operation->getPackage()->getName()); + $operationPackageName = $operation instanceof UpdateOperation ? $operation->getTargetPackage()->getName() : $operation->getPackage()->getName(); + + $isRequestedPiePackage = $this->composerRequest->isFor($operationPackageName); if (! $isRequestedPiePackage) { $pieOutput->writeError( sprintf( 'Filtering package %s from install operations, as it was not the requested package', - $operation->getPackage()->getName(), + $operationPackageName, ), verbosity: IOInterface::VERY_VERBOSE, ); diff --git a/src/ComposerIntegration/PiePackageInstaller.php b/src/ComposerIntegration/PiePackageInstaller.php index 8fb583e8..b0e76dee 100644 --- a/src/ComposerIntegration/PiePackageInstaller.php +++ b/src/ComposerIntegration/PiePackageInstaller.php @@ -39,40 +39,38 @@ public function install(InstalledRepositoryInterface $repo, PackageInterface $pa $composerPackage = $package; return parent::install($repo, $composerPackage) - ?->then(function () use ($composerPackage) { - $io = $this->composerRequest->pieOutput; - - if (! $this->composerRequest->isFor($composerPackage->getName())) { - $io->write( - sprintf( - 'Skipping %s install request from Composer as it was not the expected PIE package(s) %s', - $composerPackage->getName(), - implode(', ', array_map(static fn (RequestedPackageAndVersion $req) => $req->package, $this->composerRequest->requestedPackages)), - ), - verbosity: IOInterface::VERY_VERBOSE, + ?->then($this->onlyForRequestedPiePackage( + $composerPackage, + 'install', + function (CompletePackageInterface $composerPackage): void { + ($this->installAndBuildProcess)( + $this->composer, + $this->composerRequest, + $composerPackage, + $this->getInstallPath($composerPackage), ); + }, + )); + } - return null; - } - - if (! $composerPackage instanceof CompletePackageInterface) { - $io->writeError(sprintf( - 'Not using PIE to install %s as it was not a Complete Package', - $composerPackage->getName(), - )); - - return null; - } - - ($this->installAndBuildProcess)( - $this->composer, - $this->composerRequest, - $composerPackage, - $this->getInstallPath($composerPackage), - ); - - return null; - }); + /** @inheritDoc */ + public function update(InstalledRepositoryInterface $repo, PackageInterface $initial, PackageInterface $target) + { + $composerPackage = $target; + + return parent::update($repo, $initial, $target) + ?->then($this->onlyForRequestedPiePackage( + $composerPackage, + 'update', + function (CompletePackageInterface $composerPackage): void { + ($this->installAndBuildProcess)( + $this->composer, + $this->composerRequest, + $composerPackage, + $this->getInstallPath($composerPackage), + ); + }, + )); } /** @inheritDoc */ @@ -81,37 +79,56 @@ public function uninstall(InstalledRepositoryInterface $repo, PackageInterface $ $composerPackage = $package; return parent::uninstall($repo, $composerPackage) - ?->then(function () use ($composerPackage) { - $io = $this->composerRequest->pieOutput; - - if (! $this->composerRequest->isFor($composerPackage->getName())) { - $io->write( - sprintf( - 'Skipping %s uninstall request from Composer as it was not the expected PIE package(s) %s', - $composerPackage->getName(), - implode(', ', array_map(static fn (RequestedPackageAndVersion $req) => $req->package, $this->composerRequest->requestedPackages)), - ), - verbosity: IOInterface::VERY_VERBOSE, + ?->then($this->onlyForRequestedPiePackage( + $composerPackage, + 'uninstall', + function (CompletePackageInterface $composerPackage): void { + ($this->uninstallProcess)( + $this->composerRequest, + $composerPackage, ); + }, + )); + } - return null; - } + /** + * Wraps the given callback so it only runs when Composer's operation is for a PIE package we actually + * requested, and that package has full metadata available. + * + * @return callable(): null + */ + private function onlyForRequestedPiePackage(PackageInterface $composerPackage, string $verb, callable $onVerified): callable + { + return function () use ($composerPackage, $verb, $onVerified) { + $io = $this->composerRequest->pieOutput; - if (! $composerPackage instanceof CompletePackageInterface) { - $io->writeError(sprintf( - 'Not using PIE to install %s as it was not a Complete Package', + if (! $this->composerRequest->isFor($composerPackage->getName())) { + $io->write( + sprintf( + 'Skipping %s %s request from Composer as it was not the expected PIE package(s) %s', $composerPackage->getName(), - )); + $verb, + implode(', ', array_map(static fn (RequestedPackageAndVersion $req) => $req->package, $this->composerRequest->requestedPackages)), + ), + verbosity: IOInterface::VERY_VERBOSE, + ); - return null; - } + return null; + } - ($this->uninstallProcess)( - $this->composerRequest, - $composerPackage, - ); + if (! $composerPackage instanceof CompletePackageInterface) { + $io->writeError(sprintf( + 'Not using PIE to %s %s as it was not a Complete Package', + $verb, + $composerPackage->getName(), + )); return null; - }); + } + + $onVerified($composerPackage); + + return null; + }; } } diff --git a/src/Container.php b/src/Container.php index aa9e3ade..bf547bf3 100644 --- a/src/Container.php +++ b/src/Container.php @@ -26,6 +26,7 @@ use Php\Pie\Command\SelfVerifyCommand; use Php\Pie\Command\ShowCommand; use Php\Pie\Command\UninstallCommand; +use Php\Pie\Command\UpgradeCommand; use Php\Pie\ComposerIntegration\MinimalHelperSet; use Php\Pie\ComposerIntegration\QuieterConsoleIO; use Php\Pie\DependencyResolver\DependencyInstaller\SystemDependenciesDefinition; @@ -114,6 +115,7 @@ static function (ConsoleCommandEvent $event) use (&$displayedBanner, $io): void $container->singleton(DownloadCommand::class); $container->singleton(BuildCommand::class); $container->singleton(InstallCommand::class); + $container->singleton(UpgradeCommand::class); $container->singleton(InfoCommand::class); $container->singleton(ShowCommand::class); $container->singleton(RepositoryListCommand::class); diff --git a/test/assets/pie-lock/pie.json b/test/assets/pie-install-from-lock/pie.json similarity index 100% rename from test/assets/pie-lock/pie.json rename to test/assets/pie-install-from-lock/pie.json diff --git a/test/assets/pie-lock/pie.lock b/test/assets/pie-install-from-lock/pie.lock similarity index 100% rename from test/assets/pie-lock/pie.lock rename to test/assets/pie-install-from-lock/pie.lock diff --git a/test/assets/pie-upgrade-lock/pie.json b/test/assets/pie-upgrade-lock/pie.json new file mode 100644 index 00000000..b4ecacbd --- /dev/null +++ b/test/assets/pie-upgrade-lock/pie.json @@ -0,0 +1,6 @@ +{ + "require": { + "xdebug/xdebug": "^3.5", + "asgrim/example-pie-extension": "^2.0" + } +} diff --git a/test/assets/pie-upgrade-lock/pie.lock b/test/assets/pie-upgrade-lock/pie.lock new file mode 100644 index 00000000..1b00d4aa --- /dev/null +++ b/test/assets/pie-upgrade-lock/pie.lock @@ -0,0 +1,115 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "52fcc35c994bb87556f462b69bab5d92", + "packages": [ + { + "name": "asgrim/example-pie-extension", + "version": "2.0.7", + "source": { + "type": "git", + "url": "https://github.com/asgrim/example-pie-extension.git", + "reference": "a84a5fefc4065bedfd7750e7e2737b29f762700f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/asgrim/example-pie-extension/zipball/a84a5fefc4065bedfd7750e7e2737b29f762700f", + "reference": "a84a5fefc4065bedfd7750e7e2737b29f762700f", + "shasum": "" + }, + "require": { + "php": ">=7.4,^8.0" + }, + "replace": { + "ext-example_pie_extension": "*" + }, + "type": "php-ext", + "notification-url": "https://packagist.org/downloads/", + "php-ext": { + "priority": 74, + "extension-name": "ext-example_pie_extension", + "configure-options": [ + { + "name": "enable-example-pie-extension", + "description": "whether to enable example-pie-extension support" + }, + { + "name": "with-hello-name", + "description": "Name ot use when saying hello", + "needs-value": true + } + ], + "download-url-method": [ + "pre-packaged-binary", + "composer-default" + ] + }, + "license": [ + "MIT" + ], + "description": "Example PIE extension", + "support": { + "issues": "https://github.com/asgrim/example-pie-extension/issues", + "source": "https://github.com/asgrim/example-pie-extension/tree/2.0.7" + }, + "time": "2026-02-03T13:51:24+00:00" + }, + { + "name": "xdebug/xdebug", + "version": "3.5.3", + "source": { + "type": "git", + "url": "https://github.com/xdebug/xdebug.git", + "reference": "127bbcb980400752221cfaa54bdc1420e6ef3c12" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/xdebug/xdebug/zipball/127bbcb980400752221cfaa54bdc1420e6ef3c12", + "reference": "127bbcb980400752221cfaa54bdc1420e6ef3c12", + "shasum": "" + }, + "require": { + "php": ">=8.0,<8.6" + }, + "type": "php-ext-zend", + "notification-url": "https://packagist.org/downloads/", + "php-ext": { + "priority": 90 + }, + "license": [ + "Xdebug-1.03" + ], + "description": "Xdebug is a debugging and productivity extension for PHP", + "support": { + "source": "https://github.com/xdebug/xdebug/tree/3.5.3" + }, + "funding": [ + { + "url": "https://xdebug.org/support", + "type": "custom" + }, + { + "url": "https://github.com/derickr", + "type": "github" + }, + { + "url": "https://www.patreon.com/derickr", + "type": "patreon" + } + ], + "time": "2026-06-08T14:39:07+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": {}, + "platform-dev": {}, + "plugin-api-version": "2.9.0" +} diff --git a/test/behaviour/CliContext.php b/test/behaviour/CliContext.php index f6a9d7ad..8cd18056 100644 --- a/test/behaviour/CliContext.php +++ b/test/behaviour/CliContext.php @@ -9,6 +9,7 @@ use Behat\Step\Given; use Behat\Step\Then; use Behat\Step\When; +use Composer\Semver\VersionParser; use Composer\Util\Platform; use RuntimeException; use Safe\Exceptions\PcreException; @@ -28,6 +29,7 @@ use function sprintf; use function str_contains; use function str_replace; +use function trim; class CliContext implements Context { @@ -476,9 +478,10 @@ public function iShouldSeeAllTheExtensionsAreNowInstalled(): void $this->runPieCommand(['show']); $this->assertCommandSuccessful(); + $pieShowOutput = $this->output; - Assert::contains($this->output, 'asgrim/example-pie-extension'); - Assert::contains($this->output, 'phpredis/phpredis'); + self::assertPackageVersionInstalledInPieShowOutput($pieShowOutput, 'asgrim/example-pie-extension'); + self::assertPackageVersionInstalledInPieShowOutput($pieShowOutput, 'phpredis/phpredis'); } #[Then('I should see information on how to select packages for install')] @@ -550,11 +553,9 @@ public function iShouldSeeItHasFailedVerification(): void Assert::contains($this->errorOutput, '❌ Failed to verify that this PIE binary is the authentic release'); } - #[Given('I have a lock file')] - public function iHaveALockfile(): void + private function copyPieJsonAndLock(string $asset): void { - $this->runPieCommand(['install', 'xdebug/xdebug:3.5.3', 'derickr/quickhash']); - + // Find existing pie.json $this->runPieCommand(['show', '-v']); Assert::notNull($this->output); preg_match('#Using pie\.json: (.+)#', $this->output, $pieJsonRegexMatches); @@ -562,20 +563,96 @@ public function iHaveALockfile(): void $this->pieJsonFilename = $pieJsonRegexMatches[1]; $this->pieLockFilename = str_replace('pie.json', 'pie.lock', $this->pieJsonFilename); + // Make a backup of the pie.json/.lock $this->pieJsonContentBackup = file_get_contents($this->pieJsonFilename); $this->pieLockContentBackup = file_get_contents($this->pieLockFilename); - $testPieLockPath = realpath(__DIR__ . '/../assets/pie-lock'); + // Copy the new ones over + $testPieLockPath = realpath(__DIR__ . '/../assets/' . $asset); copy($testPieLockPath . '/pie.json', $this->pieJsonFilename); copy($testPieLockPath . '/pie.lock', $this->pieLockFilename); } + private function restorePieJsonAndLock(): void + { + file_put_contents($this->pieJsonFilename, $this->pieJsonContentBackup); + file_put_contents($this->pieLockFilename, $this->pieLockContentBackup); + $this->runPieCommand(['install', '--from-lock']); + } + + #[Given('I have installed PIE extensions that have upgrades available')] + public function iHaveInstalledPieExtensionsThatHaveUpgradesAvailable(): void + { + $this->runPieCommand(['install', 'asgrim/example-pie-extension:2.0.7']); + $this->copyPieJsonAndLock('pie-upgrade-lock'); + } + + #[Given('I have installed PIE extensions with configure options that have upgrades available')] + public function iHaveInstalledPieExtensionsThatHaveConfigureOptions(): void + { + $this->runPieCommand(['install', 'asgrim/example-pie-extension:2.0.7', '--with-hello-name=UpgradeTest']); + $this->copyPieJsonAndLock('pie-upgrade-lock'); + } + + #[Given('I have a lock file')] + public function iHaveALockfile(): void + { + $this->runPieCommand(['install', 'xdebug/xdebug:3.5.3', 'derickr/quickhash']); + $this->copyPieJsonAndLock('pie-install-from-lock'); + } + #[When('I run a command to install from the lockfile')] public function iRunACommandToInstallFromTheLockfile(): void { $this->runPieCommand(['install', '-v', '--from-lock']); } + #[When('I run a command to upgrade my extensions')] + public function iRunACommandToUpgradeMyExtensions(): void + { + $this->runPieCommand(['upgrade', '-v']); + } + + /** @return array */ + private static function installedExtensionPackagesAndVersions(string $pieShowOutput): array + { + if (! preg_match_all('#([a-zA-Z0-9-_]+/[a-zA-Z0-9-_]+):([^ ]+)#', $pieShowOutput, $matches)) { + throw new RuntimeException('no packages found in pie show'); + } + + return array_combine($matches[1], $matches[2]); + } + + private static function assertPackageVersionInstalledInPieShowOutput(string $pieShowOutput, string $expectedPackage, string|null $expectedVersion = null): void + { + $installedExtensionPackagesAndVersions = self::installedExtensionPackagesAndVersions($pieShowOutput); + Assert::keyExists($installedExtensionPackagesAndVersions, $expectedPackage); + + if ($expectedVersion === null) { + return; + } + + $versionParser = new VersionParser(); + $installedConstraint = $versionParser->parseConstraints($installedExtensionPackagesAndVersions[$expectedPackage]); + $expectedConstraint = $versionParser->parseConstraints($expectedVersion); + Assert::true( + $expectedConstraint->matches($installedConstraint), + sprintf( + 'Installed version %s does not match expected constraint %s', + $installedConstraint->getPrettyString(), + $expectedConstraint->getPrettyString(), + ), + ); + } + + private static function assertPackageNotInstalledInPieShowOutput(string $pieShowOutput, string $notExpectedPackage): void + { + Assert::keyNotExists( + self::installedExtensionPackagesAndVersions($pieShowOutput), + $notExpectedPackage, + ); + } + #[Then('the extensions should have been updated to the lock')] public function theExtensionsShouldHaveBeenUpdatedToTheLock(): void { @@ -586,31 +663,59 @@ public function theExtensionsShouldHaveBeenUpdatedToTheLock(): void $this->runPieCommand(['show']); $this->assertCommandSuccessful(); - - if (! preg_match_all('#([a-zA-Z0-9-_]+/[a-zA-Z0-9-_]+):([^ ]+)#', (string) $this->output, $matches)) { - throw new RuntimeException('no packages found in pie show'); - } - - $installedExtensionPackagesAndVersions = array_combine($matches[1], $matches[2]); + $pieShowOutput = $this->output; // `xdebug` should be downgraded to 3.5.2 Assert::contains($pieInstallOutput, 'PIE package xdebug/xdebug (xdebug) is at 3.5.3 but the lock requires 3.5.2, scheduling for reinstall'); Assert::contains($pieInstallOutput, 'Extension xdebug/xdebug:3.5.2 is enabled and loaded'); - Assert::keyExists($installedExtensionPackagesAndVersions, 'xdebug/xdebug'); - Assert::same($installedExtensionPackagesAndVersions['xdebug/xdebug'], '3.5.2'); + self::assertPackageVersionInstalledInPieShowOutput($pieShowOutput, 'xdebug/xdebug', '3.5.2'); // `quickhash` should have been removed (not in lockfile) Assert::contains($pieInstallOutput, 'Removed extension derickr/quickhash:'); - Assert::keyNotExists($installedExtensionPackagesAndVersions, 'derickr/quickhash'); + self::assertPackageNotInstalledInPieShowOutput($pieShowOutput, 'derickr/quickhash'); // `example_pie_extension` 2.0.9 should have been installed (was not previously installed) Assert::contains($pieInstallOutput, 'Extension asgrim/example-pie-extension:2.0.9 is enabled and loaded'); - Assert::keyExists($installedExtensionPackagesAndVersions, 'asgrim/example-pie-extension'); - Assert::same($installedExtensionPackagesAndVersions['asgrim/example-pie-extension'], '2.0.9'); + self::assertPackageVersionInstalledInPieShowOutput($pieShowOutput, 'asgrim/example-pie-extension', '2.0.9'); - // restore the pie.json/lock contents, and re-install from the lock - file_put_contents($this->pieJsonFilename, $this->pieJsonContentBackup); - file_put_contents($this->pieLockFilename, $this->pieLockContentBackup); - $this->runPieCommand(['install', '--from-lock']); + $this->restorePieJsonAndLock(); + } + + #[Then('the extensions should have been upgraded to the latest versions')] + public function theExtensionsShouldHaveBeenUpgradedToTheLatestVersions(): void + { + $this->runPieCommand(['show']); + $this->assertCommandSuccessful(); + $pieShowOutput = $this->output; + + // xdebug/xdebug should still exist (upgrade should NOT uninstall it) + self::assertPackageVersionInstalledInPieShowOutput($pieShowOutput, 'xdebug/xdebug'); + + // asgrim/example-pie-extension should be newer than 2.0.7 (min 2.0.9 at time of writing) + self::assertPackageVersionInstalledInPieShowOutput($pieShowOutput, 'asgrim/example-pie-extension', '^2.0.9'); + + $this->restorePieJsonAndLock(); + } + + #[Then('the extension has been upgraded with the previous configure options')] + public function theExtensionsShouldHaveBeenUpgradedWithThePreviousConfigureOptions(): void + { + $this->runPieCommand(['show']); + $this->assertCommandSuccessful(); + $pieShowOutput = $this->output; + + // xdebug/xdebug should still exist (upgrade should NOT uninstall it) + self::assertPackageVersionInstalledInPieShowOutput($pieShowOutput, 'xdebug/xdebug'); + + // asgrim/example-pie-extension should be newer than 2.0.7 (min 2.0.9 at time of writing) + self::assertPackageVersionInstalledInPieShowOutput($pieShowOutput, 'asgrim/example-pie-extension', '^2.0.9'); + + $exampleTest = (new Process([self::PHP_BINARY, '-r', 'example_pie_extension_test();'])) + ->mustRun() + ->getOutput(); + + Assert::same(trim($exampleTest), 'Hello, UpgradeTest!'); + + $this->restorePieJsonAndLock(); } } diff --git a/test/integration/ComposerIntegration/ComposerIntegrationHandlerTest.php b/test/integration/ComposerIntegration/ComposerIntegrationHandlerTest.php index b6836c99..862045d7 100644 --- a/test/integration/ComposerIntegration/ComposerIntegrationHandlerTest.php +++ b/test/integration/ComposerIntegration/ComposerIntegrationHandlerTest.php @@ -146,6 +146,42 @@ public function testRunInstallReinstallsVerifiedPackageWhenVersionDiffers(): voi self::assertStringNotContainsString('is already installed and verified', $output); } + public function testRunInstallWithNoResolvedPackagesTreatsLockedPackagesAsExtensionNames(): void + { + PieJsonEditor::fromTargetPlatform($this->targetPlatform) + ->ensureExists() + ->addRequire(self::PACKAGE_NAME, self::VERSION_CURRENT); + $this->setUpInstalledJson(self::VERSION_CURRENT); + $this->setUpLockFile(); + + $composer = PieComposerFactory::createPieComposer( + Container::testFactory($this->capturedOutput), + new PieComposerRequest( + new NullIO(), + $this->targetPlatform, + [], + PieOperation::Install, + [], + false, + installAllPackages: true, + ), + ); + + $this->handler->runInstall( + [], + $composer, + $this->targetPlatform, + false, + false, + ); + + $output = $this->capturedOutput->fetch(); + self::assertStringContainsString( + self::PACKAGE_NAME . ' (' . self::EXTENSION_NAME . ') is already installed and verified', + $output, + ); + } + public function testRunUninstallRemovesPackageFromPieJson(): void { PieJsonEditor::fromTargetPlatform($this->targetPlatform) @@ -180,14 +216,19 @@ private function extensionBinaryPath(): string /** @param non-empty-string $version */ private function makeComposerPackage(string $version): CompletePackage { + $sha = '963c8d70c57c23fa2098e499a0ebffabb64748b3'; $extensionBinaryPath = $this->extensionBinaryPath(); $package = new CompletePackage(self::PACKAGE_NAME, $version . '.0', $version); $package->setType('php-ext'); $package->setPhpExt(['extension-name' => 'ext-' . self::EXTENSION_NAME]); $package->setDistType('zip'); - $package->setDistUrl('https://github.com/asgrim/example-pie-extension/archive/refs/tags/' . $version . '.zip'); $package->setInstallationSource('dist'); + $package->setDistUrl('https://api.github.com/repos/asgrim/example-pie-extension/zipball/' . $sha); + $package->setDistReference($sha); + $package->setSourceType('git'); + $package->setSourceUrl('https://github.com/asgrim/example-pie-extension.git'); + $package->setSourceReference($sha); $package->setExtra([ InstalledJsonMetadata::KEY_TARGET_PLATFORM_PHP_VERSION => $this->targetPlatform->phpBinaryPath->version(), InstalledJsonMetadata::KEY_BUILT_BINARY => $extensionBinaryPath, @@ -212,7 +253,7 @@ private function setUpInstalledJson(string $version): void private function setUpLockFile(): void { - copy(__DIR__ . '/../../assets/pie-lock/pie.lock', Platform::getPieWorkingDirectory($this->targetPlatform) . '/pie.lock'); + copy(__DIR__ . '/../../assets/pie-install-from-lock/pie.lock', Platform::getPieWorkingDirectory($this->targetPlatform) . '/pie.lock'); } /** @param non-empty-string $version */ diff --git a/test/unit/ComposerIntegration/Listeners/OverrideDownloadUrlInstallListenerTest.php b/test/unit/ComposerIntegration/Listeners/OverrideDownloadUrlInstallListenerTest.php index e2e64fa8..022b05b1 100644 --- a/test/unit/ComposerIntegration/Listeners/OverrideDownloadUrlInstallListenerTest.php +++ b/test/unit/ComposerIntegration/Listeners/OverrideDownloadUrlInstallListenerTest.php @@ -5,6 +5,7 @@ namespace Php\PieUnitTest\ComposerIntegration\Listeners; use Composer\Composer; +use Composer\DependencyResolver\Operation\UpdateOperation; use Composer\DependencyResolver\Transaction; use Composer\EventDispatcher\EventDispatcher; use Composer\Installer\InstallerEvent; @@ -32,6 +33,8 @@ use PHPUnit\Framework\TestCase; use Psr\Container\ContainerInterface; +use function array_map; + #[CoversClass(OverrideDownloadUrlInstallListener::class)] final class OverrideDownloadUrlInstallListenerTest extends TestCase { @@ -551,6 +554,72 @@ public function testPrePackagedBinaryMethodIsIgnoredWhenConfigureOptionsArePasse $listener($installerEvent); } + public function testDistUrlIsUpdatedForWindowsInstallersOnUpdateOperations(): void + { + $initialPackage = new CompletePackage('foo/bar', '1.2.3.0', '1.2.3'); + $initialPackage->setDistUrl('https://example.com/git-archive-zip-url'); + + $targetPackage = new CompletePackage('foo/bar', '1.3.0.0', '1.3.0'); + $targetPackage->setDistUrl('https://example.com/git-archive-zip-url'); + + $installerEvent = new InstallerEvent( + InstallerEvents::PRE_OPERATIONS_EXEC, + $this->composer, + $this->io, + false, + true, + new Transaction([$initialPackage], [$targetPackage]), + ); + + self::assertSame( + [UpdateOperation::class], + array_map( + static fn (object $operation): string => $operation::class, + $installerEvent->getTransaction()?->getOperations() ?? [], + ), + ); + + $packageReleaseAssets = $this->createMock(PackageReleaseAssets::class); + $packageReleaseAssets + ->expects(self::once()) + ->method('findMatchingReleaseAssetUrl') + ->willReturn('https://example.com/windows-download-url'); + + $this->container + ->method('get') + ->with(PackageReleaseAssets::class) + ->willReturn($packageReleaseAssets); + + (new OverrideDownloadUrlInstallListener( + $this->composer, + $this->io, + $this->container, + new PieComposerRequest( + $this->createMock(IOInterface::class), + new TargetPlatform( + OperatingSystem::Windows, + OperatingSystemFamily::Linux, + PhpBinaryPath::fromCurrentProcess(), + Architecture::x86_64, + ThreadSafetyMode::NonThreadSafe, + 1, + WindowsCompiler::VC15, + null, + ), + [new RequestedPackageAndVersion('foo/bar', '^1.1')], + PieOperation::Install, + [], + false, + ), + ))($installerEvent); + + self::assertSame( + 'https://example.com/windows-download-url', + $targetPackage->getDistUrl(), + ); + self::assertSame(DownloadUrlMethod::WindowsBinaryDownload, DownloadUrlMethod::fromComposerPackage($targetPackage)); + } + public function testNoSelectedDownloadUrlMethodWillThrowException(): void { $composerPackage = new CompletePackage('foo/bar', '1.2.3.0', '1.2.3'); diff --git a/test/unit/ComposerIntegration/Listeners/RemoveUnrelatedInstallOperationsTest.php b/test/unit/ComposerIntegration/Listeners/RemoveUnrelatedInstallOperationsTest.php index b7fe028b..1a82c685 100644 --- a/test/unit/ComposerIntegration/Listeners/RemoveUnrelatedInstallOperationsTest.php +++ b/test/unit/ComposerIntegration/Listeners/RemoveUnrelatedInstallOperationsTest.php @@ -7,6 +7,7 @@ use Composer\Composer; use Composer\DependencyResolver\Operation\InstallOperation; use Composer\DependencyResolver\Operation\OperationInterface; +use Composer\DependencyResolver\Operation\UpdateOperation; use Composer\DependencyResolver\Transaction; use Composer\EventDispatcher\EventDispatcher; use Composer\Installer\InstallerEvent; @@ -127,4 +128,60 @@ public function testUnrelatedInstallOperationsAreRemoved(): void ), ); } + + public function testUnrelatedUpdateOperationsAreRemoved(): void + { + $keepInitial = new CompletePackage('bat/baz', '3.4.5.0', '3.4.5'); + $keepTarget = new CompletePackage('bat/baz', '3.5.0.0', '3.5.0'); + $discardInitial = new CompletePackage('foo/bar', '1.2.3.0', '1.2.3'); + $discardTarget = new CompletePackage('foo/bar', '1.3.0.0', '1.3.0'); + + $installerEvent = new InstallerEvent( + InstallerEvents::PRE_OPERATIONS_EXEC, + $this->composer, + $this->createMock(IOInterface::class), + false, + true, + new Transaction([$keepInitial, $discardInitial], [$keepTarget, $discardTarget]), + ); + + self::assertSame( + [UpdateOperation::class, UpdateOperation::class], + array_map( + static fn (object $operation): string => $operation::class, + $installerEvent->getTransaction()?->getOperations() ?? [], + ), + ); + + (new RemoveUnrelatedInstallOperations( + new PieComposerRequest( + $this->createMock(IOInterface::class), + new TargetPlatform( + OperatingSystem::Windows, + OperatingSystemFamily::Linux, + PhpBinaryPath::fromCurrentProcess(), + Architecture::x86_64, + ThreadSafetyMode::NonThreadSafe, + 1, + WindowsCompiler::VC15, + null, + ), + [new RequestedPackageAndVersion('bat/baz', '^3.2')], + PieOperation::Install, + [], + false, + ), + ))($installerEvent); + + self::assertSame( + ['bat/baz'], + array_map( + static fn (UpdateOperation $operation): string => $operation->getTargetPackage()->getName(), + array_filter( + $installerEvent->getTransaction()?->getOperations() ?? [], + static fn (OperationInterface $operation): bool => $operation instanceof UpdateOperation, + ), + ), + ); + } }