diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index 22391281..86e8dd3e 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -160,6 +160,7 @@ jobs: - '8.2' - '8.3' - '8.4' + - '8.5' steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3 with: diff --git a/features/install-extensions.feature b/features/install-extensions.feature index a2b6ebe6..a4a8648e 100644 --- a/features/install-extensions.feature +++ b/features/install-extensions.feature @@ -14,3 +14,9 @@ Feature: Extensions can be installed with PIE Example: Multiple extensions can be installed at once When I run a command to install multiple extensions Then the extensions should have been installed and enabled + + # pie install --from-lock + Example: I can install exact versions contained in the lockfile + Given I have a lock file + When I run a command to install from the lockfile + Then the extensions should have been updated to the lock diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 6582e98a..7c987dfe 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -9,6 +9,7 @@ beStrictAboutOutputDuringTests="true" displayDetailsOnSkippedTests="true" displayDetailsOnTestsThatTriggerWarnings="true" + displayDetailsOnPhpunitDeprecations="true" failOnRisky="true" failOnWarning="true"> diff --git a/src/Command/CommandHelper.php b/src/Command/CommandHelper.php index c16849dc..6bf0f0f5 100644 --- a/src/Command/CommandHelper.php +++ b/src/Command/CommandHelper.php @@ -395,7 +395,7 @@ static function (RequestedPackageAndVersion $requestedNameAndVersion) use ($depe } /** - * @param non-empty-list $packages + * @param list $packages * * @throws ConfigureOptionCollision if two of the requested packages declare a configure option with the same name. */ @@ -429,7 +429,7 @@ public static function bindConfigureOptionsFromPackage(Command $command, array $ } /** - * @param non-empty-list $packages + * @param list $packages * * @return array> Keyed by package name */ diff --git a/src/Command/InstallCommand.php b/src/Command/InstallCommand.php index 618d0114..d23ec4d3 100644 --- a/src/Command/InstallCommand.php +++ b/src/Command/InstallCommand.php @@ -5,6 +5,7 @@ namespace Php\Pie\Command; use Composer\IO\IOInterface; +use InvalidArgumentException; use Php\Pie\ComposerIntegration\ComposerIntegrationHandler; use Php\Pie\ComposerIntegration\ComposerRunFailed; use Php\Pie\ComposerIntegration\PieComposerFactory; @@ -24,6 +25,7 @@ use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Throwable; @@ -33,6 +35,8 @@ )] final class InstallCommand extends Command { + public const OPTION_FROM_LOCK = 'from-lock'; + public function __construct( private readonly ContainerInterface $container, private readonly DependencyResolver $dependencyResolver, @@ -51,11 +55,31 @@ public function configure(): void parent::configure(); CommandHelper::configureDownloadBuildInstallOptions($this); + + $this->addOption( + self::OPTION_FROM_LOCK, + null, + InputOption::VALUE_NONE, + 'Install the exact versions specified in the pie.lock file for the target PHP.', + ); + } + + public static function shouldInstallFromLock(InputInterface $input): bool + { + return $input->hasOption(self::OPTION_FROM_LOCK) && (bool) $input->getOption(self::OPTION_FROM_LOCK); } public function execute(InputInterface $input, OutputInterface $output): int { - if (! $input->getArgument(CommandHelper::ARG_REQUESTED_PACKAGE_AND_VERSION)) { + $installFromLock = self::shouldInstallFromLock($input); + + if ($installFromLock && $input->getArgument(CommandHelper::ARG_REQUESTED_PACKAGE_AND_VERSION)) { + $this->io->writeError('The --from-lock option installs all extensions from the lock file and cannot be combined with a specific package argument.'); + + return self::INVALID; + } + + if (! $installFromLock && ! $input->getArgument(CommandHelper::ARG_REQUESTED_PACKAGE_AND_VERSION)) { return ($this->invokeSubCommand)( $this, ['command' => 'install-extensions-for-project'], @@ -78,6 +102,12 @@ public function execute(InputInterface $input, OutputInterface $output): int $targetPlatform, $this->container, ); + } catch (InvalidArgumentException $noPackagesRequested) { + if (! $installFromLock) { + throw $noPackagesRequested; + } + + $requestedNamesAndVersions = []; } $forceInstallPackageVersion = CommandHelper::determineForceInstallingPackageVersion($input); @@ -122,15 +152,18 @@ public function execute(InputInterface $input, OutputInterface $output): int } } + $resolvedPackages = []; try { - $resolvedPackages = CommandHelper::resolveRequestedPackages( - $this->dependencyResolver, - $this->io, - $composer, - $targetPlatform, - $requestedNamesAndVersions, - $forceInstallPackageVersion, - ); + if ($requestedNamesAndVersions !== []) { + $resolvedPackages = CommandHelper::resolveRequestedPackages( + $this->dependencyResolver, + $this->io, + $composer, + $targetPlatform, + $requestedNamesAndVersions, + $forceInstallPackageVersion, + ); + } } catch (UnableToResolveRequirement $unableToResolveRequirement) { return CommandHelper::handlePackageNotFound( $unableToResolveRequirement, @@ -161,6 +194,7 @@ public function execute(InputInterface $input, OutputInterface $output): int PieOperation::Install, $configureOptionsValues, CommandHelper::determineAttemptToSetupIniFile($input), + installAllPackages: $installFromLock, ), ); @@ -171,6 +205,7 @@ public function execute(InputInterface $input, OutputInterface $output): int $targetPlatform, $forceInstallPackageVersion, true, + installFromLock: $installFromLock, ); } catch (ComposerRunFailed $composerRunFailed) { $this->io->writeError('' . $composerRunFailed->getMessage() . ''); diff --git a/src/Command/ShowCommand.php b/src/Command/ShowCommand.php index d43f5f41..38d64f5d 100644 --- a/src/Command/ShowCommand.php +++ b/src/Command/ShowCommand.php @@ -6,6 +6,7 @@ use Composer\IO\IOInterface; use Composer\IO\NullIO; +use InvalidArgumentException; use Php\Pie\ComposerIntegration\PieComposerFactory; use Php\Pie\ComposerIntegration\PieComposerRequest; use Php\Pie\DependencyResolver\BundledPhpExtensionRefusal; @@ -26,6 +27,7 @@ use Webmozart\Assert\Assert; use function array_diff; +use function array_key_exists; use function array_map; use function array_walk; use function count; @@ -115,7 +117,7 @@ function (string $version, string $phpExtensionName) use ($composer, $rootPackag foreach ($pieMatchesForExtension->packages() as $piePackage) { $packageName = $piePackage->name(); $verificationStatus = $piePackage->verifyPackageStatus($targetPlatform); - $packageRequirement = $rootPackageRequires[$packageName]->getPrettyConstraint(); + $packageRequirement = array_key_exists($packageName, $rootPackageRequires) ? $rootPackageRequires[$packageName]->getPrettyConstraint() : null; if ($verificationStatus === PackageVerificationStatus::InstalledBinaryMetadataMissing) { continue; @@ -145,7 +147,7 @@ function (string $version, string $phpExtensionName) use ($composer, $rootPackag new RequestedPackageAndVersion($packageName, '*'), false, ); - } catch (UnableToResolveRequirement | BundledPhpExtensionRefusal) { + } catch (UnableToResolveRequirement | BundledPhpExtensionRefusal | InvalidArgumentException) { $latestConstrainedPackage = null; $latestPackage = null; } @@ -163,6 +165,10 @@ function (string $version, string $phpExtensionName) use ($composer, $rootPackag $updateNotice .= sprintf(', latest version is %s', $latestPackage->piePackage->version()); } + if (! array_key_exists($packageName, $rootPackageRequires)) { + $verificationStatus = PackageVerificationStatus::InstalledButDoesNotExistInRequires; + } + $this->io->write(sprintf( ' %s:%s (from 🥧 %s %s)%s', $phpExtensionName, diff --git a/src/ComposerIntegration/ComposerIntegrationHandler.php b/src/ComposerIntegration/ComposerIntegrationHandler.php index 82009239..26e80f91 100644 --- a/src/ComposerIntegration/ComposerIntegrationHandler.php +++ b/src/ComposerIntegration/ComposerIntegrationHandler.php @@ -19,6 +19,7 @@ use Psr\Container\ContainerInterface; use function array_map; +use function array_values; use function assert; use function file_exists; use function sprintf; @@ -72,6 +73,7 @@ public function runInstall( TargetPlatform $targetPlatform, bool $forceInstallPackageVersion, bool $runCleanup, + bool $installFromLock = false, ): void { $pieComposerJson = Platform::getPieJsonFilename($targetPlatform); $pieJsonEditor = PieJsonEditor::fromTargetPlatform($targetPlatform); @@ -90,7 +92,14 @@ public function runInstall( // Refresh the Composer instance so it re-reads the updated pie.json $composer = PieComposerFactory::recreatePieComposer($this->container, $composer); - foreach ($composer->getRepositoryManager()->getLocalRepository()->getPackages() as $localRepoPackage) { + $resolvedPackageByName = []; + foreach ($resolvedRequestedPackages as $resolvedPackageRequest) { + $resolvedPackageByName[$resolvedPackageRequest->piePackage->composerPackage()->getName()] = $resolvedPackageRequest->piePackage; + } + + $localRepository = $composer->getRepositoryManager()->getLocalRepository(); + + foreach ($localRepository->getPackages() as $localRepoPackage) { $extName = ExtensionName::determineFromComposerPackage($localRepoPackage); if ($localRepoPackage instanceof CompleteAliasPackage) { @@ -110,6 +119,47 @@ public function runInstall( ), verbosity: IOInterface::VERY_VERBOSE); if ($status->isVerified() && ! $forceInstallPackageVersion) { + if ($installFromLock) { + $lockedRepo = $composer->getLocker()->getLockedRepository(); + $lockedPackage = $lockedRepo->findPackage($localRepoPackage->getName(), '*'); + + if ($lockedPackage === null) { + // Not in locked repo; Composer will install it anyway + continue; + } + + if ($lockedPackage->getVersion() !== $localRepoPackage->getVersion()) { + $this->arrayCollectionIo->write(sprintf( + '%s PIE package %s (%s) is at %s but the lock requires %s, scheduling for reinstall.', + Emoji::WARNING, + $localRepoPackage->getName(), + $extName->name(), + $localRepoPackage->getPrettyVersion(), + $lockedPackage->getPrettyVersion(), + )); + + // Locked, but the version we installed is different + $localRepository->removePackage($localRepoPackage); + continue; + } + } else { + $resolvedPackage = $resolvedPackageByName[$localRepoPackage->getName()] ?? null; + if ($resolvedPackage !== null && $resolvedPackage->composerPackage()->getVersion() !== $localRepoPackage->getVersion()) { + $this->arrayCollectionIo->write(sprintf( + '%s PIE package %s (%s) is at %s but %s was installed, adding to install candidates.', + Emoji::WARNING, + $localRepoPackage->getName(), + $extName->name(), + $localRepoPackage->getPrettyVersion(), + $resolvedPackage->composerPackage()->getPrettyVersion(), + )); + + // Resolved a different version than what's installed + $localRepository->removePackage($localRepoPackage); + continue; + } + } + $this->arrayCollectionIo->write(sprintf( '%s PIE package %s (%s) is already installed and verified.', Emoji::GREEN_CHECKMARK, @@ -144,12 +194,16 @@ public function runInstall( $extName->name(), $status->description(), )); - $composer->getRepositoryManager()->getLocalRepository()->removePackage($localRepoPackage); + $localRepository->removePackage($localRepoPackage); } + $extensionNames = $installFromLock + ? array_values(array_map(ExtensionName::determineFromComposerPackage(...), $composer->getLocker()->getLockedRepository()->getPackages())) + : ResolvedPackageRequest::extensionNames($resolvedRequestedPackages); + $composerInstaller = PieComposerInstaller::createWithPhpBinary( $targetPlatform->phpBinaryPath, - ResolvedPackageRequest::extensionNames($resolvedRequestedPackages), + $extensionNames, $this->arrayCollectionIo, $composer, ); @@ -161,7 +215,7 @@ public function runInstall( ->setPlatformRequirementFilter(PlatformRequirementFilterFactory::fromBoolOrList($forceInstallPackageVersion)) ->setDownloadOnly(false); - if (file_exists(PieComposerFactory::getLockFile($pieComposerJson))) { + if (! $installFromLock && file_exists(PieComposerFactory::getLockFile($pieComposerJson))) { $composerInstaller->setUpdate(true); $composerInstaller->setUpdateAllowList(ResolvedPackageRequest::requestedPackageNames($resolvedRequestedPackages)); } diff --git a/src/ComposerIntegration/PieComposerRequest.php b/src/ComposerIntegration/PieComposerRequest.php index 5c137853..fd024ec0 100644 --- a/src/ComposerIntegration/PieComposerRequest.php +++ b/src/ComposerIntegration/PieComposerRequest.php @@ -32,6 +32,7 @@ public function __construct( public readonly PieOperation $operation, public readonly array $configureOptions, public readonly bool $attemptToSetupIniFile, + public readonly bool $installAllPackages = false, ) { $this->requestedPackageNames = array_map(static fn (RequestedPackageAndVersion $request) => $request->package, $this->requestedPackages); } @@ -62,6 +63,10 @@ public static function noOperation( public function isFor(string $packageName): bool { + if ($this->installAllPackages) { + return true; + } + return in_array($packageName, $this->requestedPackageNames); } } diff --git a/src/ComposerIntegration/UninstallProcess.php b/src/ComposerIntegration/UninstallProcess.php index 80b10489..01e44fec 100644 --- a/src/ComposerIntegration/UninstallProcess.php +++ b/src/ComposerIntegration/UninstallProcess.php @@ -36,7 +36,11 @@ public function __invoke( $status = $piePackage->verifyPackageStatus($composerRequest->targetPlatform); if ($status->isVerified()) { - $io->write(sprintf('👋 Removed extension: %s', ($this->uninstall)($targetPlatform, $piePackage)->filePath)); + $io->write(sprintf( + '👋 Removed extension %s: %s', + $piePackage->prettyNameAndVersion(), + ($this->uninstall)($targetPlatform, $piePackage)->filePath, + )); } else { $io->writeError(sprintf('Did not remove extension file: %s', $status->description())); } diff --git a/src/Container.php b/src/Container.php index 0e79508d..aa9e3ade 100644 --- a/src/Container.php +++ b/src/Container.php @@ -229,7 +229,7 @@ static function (): SystemDependenciesDefinition { return $container; } - public static function testFactory(): ContainerInterface + public static function testFactory(OutputInterface $output = new NullOutput()): ContainerInterface { self::$testBuffer ??= new BufferIO(); @@ -243,10 +243,10 @@ public static function testFactory(): ContainerInterface // QuieterConsoleIO is wired separately from IOInterface in self::factory(), and writes // directly to a real ConsoleOutput; override it here only, so tests don't leak its output // to the terminal. - $container->singleton(QuieterConsoleIO::class, static function (ContainerInterface $container): QuieterConsoleIO { + $container->singleton(QuieterConsoleIO::class, static function (ContainerInterface $container) use ($output): QuieterConsoleIO { return new QuieterConsoleIO( $container->get(InputInterface::class), - new NullOutput(), + $output, new MinimalHelperSet( [ 'question' => new QuestionHelper(), diff --git a/src/DependencyResolver/Package.php b/src/DependencyResolver/Package.php index 6a33ca24..8e131a93 100644 --- a/src/DependencyResolver/Package.php +++ b/src/DependencyResolver/Package.php @@ -279,10 +279,10 @@ public function installedJsonMetadata(): InstalledJsonMetadata public function verifyPackageStatus(TargetPlatform $targetPlatform): PackageVerificationStatus { $extensionPath = $targetPlatform->phpBinaryPath->extensionPath(); - $extensionEnding = $targetPlatform->operatingSystem === OperatingSystem::Windows ? '.dll' : '.so'; + $isWindows = $targetPlatform->operatingSystem === OperatingSystem::Windows; $phpExtensionName = $this->extensionName->name(); - $actualBinaryPathByConvention = $extensionPath . DIRECTORY_SEPARATOR . $phpExtensionName . $extensionEnding; + $actualBinaryPathByConvention = $extensionPath . DIRECTORY_SEPARATOR . ($isWindows ? 'php_' : '') . $phpExtensionName . ($isWindows ? '.dll' : '.so'); // The extension may not be in the usual path (since you can specify a full path to an extension in the INI file) if (! file_exists($actualBinaryPathByConvention)) { diff --git a/src/DependencyResolver/ResolvedPackageRequest.php b/src/DependencyResolver/ResolvedPackageRequest.php index af5ae722..09dd2948 100644 --- a/src/DependencyResolver/ResolvedPackageRequest.php +++ b/src/DependencyResolver/ResolvedPackageRequest.php @@ -44,9 +44,9 @@ public static function requestedPackageNames(array $resolvedPackageRequests): ar } /** - * @param non-empty-list $resolvedPackageRequests + * @param list $resolvedPackageRequests * - * @return non-empty-list + * @return list */ public static function piePackages(array $resolvedPackageRequests): array { diff --git a/src/Installing/SetupIniFile.php b/src/Installing/SetupIniFile.php index f69f98f2..c1e0f487 100644 --- a/src/Installing/SetupIniFile.php +++ b/src/Installing/SetupIniFile.php @@ -34,8 +34,9 @@ public function __invoke( && $this->setupIniApproach->setup($targetPlatform, $downloadedPackage, $binaryFile, $io) ) { $io->write(sprintf( - '%s Extension is enabled and loaded in %s', + '%s Extension %s is enabled and loaded in %s', Emoji::GREEN_CHECKMARK, + $downloadedPackage->package->prettyNameAndVersion(), $targetPlatform->phpBinaryPath->phpBinaryPath, )); } else { diff --git a/src/Util/PackageVerificationStatus.php b/src/Util/PackageVerificationStatus.php index 272d8646..55f19654 100644 --- a/src/Util/PackageVerificationStatus.php +++ b/src/Util/PackageVerificationStatus.php @@ -12,6 +12,7 @@ enum PackageVerificationStatus case InstalledBinaryMetadataMissing; case ChecksumMetadataMissing; case InstalledBinaryPathDoesNotMatchActualBinaryPath; + case InstalledButDoesNotExistInRequires; public function description(): string { @@ -22,6 +23,7 @@ public function description(): string self::InstalledBinaryMetadataMissing => Emoji::WARNING . ' - installed extension metadata missing', self::ChecksumMetadataMissing => Emoji::WARNING . ' - binary checksum metadata missing', self::InstalledBinaryPathDoesNotMatchActualBinaryPath => Emoji::WARNING . ' - binary path mismatch', + self::InstalledButDoesNotExistInRequires => Emoji::WARNING . '- installed but does not exist in pie.json', }; } diff --git a/test/assets/pie-lock/pie.json b/test/assets/pie-lock/pie.json new file mode 100644 index 00000000..92317f4a --- /dev/null +++ b/test/assets/pie-lock/pie.json @@ -0,0 +1,6 @@ +{ + "require": { + "xdebug/xdebug": "3.5.2", + "asgrim/example-pie-extension": "2.0.9" + } +} diff --git a/test/assets/pie-lock/pie.lock b/test/assets/pie-lock/pie.lock new file mode 100644 index 00000000..eca995b6 --- /dev/null +++ b/test/assets/pie-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": "bbeb326cf52bdf49183176118718973c", + "packages": [ + { + "name": "asgrim/example-pie-extension", + "version": "2.0.9", + "source": { + "type": "git", + "url": "https://github.com/asgrim/example-pie-extension.git", + "reference": "963c8d70c57c23fa2098e499a0ebffabb64748b3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/asgrim/example-pie-extension/zipball/963c8d70c57c23fa2098e499a0ebffabb64748b3", + "reference": "963c8d70c57c23fa2098e499a0ebffabb64748b3", + "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.9" + }, + "time": "2026-02-04T17:27:25+00:00" + }, + { + "name": "xdebug/xdebug", + "version": "3.5.2", + "source": { + "type": "git", + "url": "https://github.com/xdebug/xdebug.git", + "reference": "f2f0ebf216d511c55d69f705f542a73b116dbf6e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/xdebug/xdebug/zipball/f2f0ebf216d511c55d69f705f542a73b116dbf6e", + "reference": "f2f0ebf216d511c55d69f705f542a73b116dbf6e", + "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.2" + }, + "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:31:52+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 5fb1b59b..f6a9d7ad 100644 --- a/test/behaviour/CliContext.php +++ b/test/behaviour/CliContext.php @@ -10,17 +10,24 @@ use Behat\Step\Then; use Behat\Step\When; use Composer\Util\Platform; +use RuntimeException; use Safe\Exceptions\PcreException; use Symfony\Component\Process\Process; use Webmozart\Assert\Assert; +use function array_combine; use function array_map; use function array_merge; +use function preg_quote; use function Safe\copy; +use function Safe\file_get_contents; +use function Safe\file_put_contents; +use function Safe\preg_match; use function Safe\preg_match_all; use function Safe\realpath; use function sprintf; use function str_contains; +use function str_replace; class CliContext implements Context { @@ -35,6 +42,10 @@ class CliContext implements Context /** @var list */ private array $interactions = []; private string|null $workingDirectory = null; + private string $pieJsonFilename; + private string $pieLockFilename; + private string $pieJsonContentBackup; + private string $pieLockContentBackup; /** @throws PcreException */ #[AfterScenario] @@ -261,9 +272,9 @@ public function theExtensionShouldNotBeInstalled(): void foreach ($this->interactions as $uninstall) { if (Platform::isWindows()) { - Assert::regex($this->output, '#👋 Removed extension: [-\\\_:.a-zA-Z0-9]+\\\php_' . $uninstall['extension'] . '.dll#'); + Assert::regex($this->output, '#👋 Removed extension ' . preg_quote($uninstall['package'], '#') . ':[^:]+: [-\\\_:.a-zA-Z0-9]+\\\php_' . preg_quote($uninstall['extension'], '#') . '.dll#'); } else { - Assert::regex($this->output, '#👋 Removed extension: [-_.a-zA-Z0-9/]+/' . $uninstall['extension'] . '.so#'); + Assert::regex($this->output, '#👋 Removed extension ' . preg_quote($uninstall['package'], '#') . ':[^:]+: [-_.a-zA-Z0-9/]+/' . preg_quote($uninstall['extension'], '#') . '.so#'); } $isExtEnabled = (new Process([self::PHP_BINARY, '-r', 'echo extension_loaded("' . $uninstall['extension'] . '")?"yes":"no";'])) @@ -302,16 +313,16 @@ public function theExtensionShouldHaveBeenInstalledAndEnabled(): void { $this->assertCommandSuccessful(); - Assert::contains($this->output, 'Extension is enabled and loaded'); - foreach ($this->interactions as $install) { + Assert::regex($this->output, '#Extension ' . preg_quote($install['package'], '#') . ':\S+ is enabled and loaded#'); + if (Platform::isWindows()) { - Assert::regex($this->output, '#Copied DLL to: [-\\\_:.a-zA-Z0-9]+\\\php_' . $install['extension'] . '.dll#'); + Assert::regex($this->output, '#Copied DLL to: [-\\\_:.a-zA-Z0-9]+\\\php_' . preg_quote($install['extension'], '#') . '.dll#'); continue; } - Assert::regex($this->output, '#Install complete: [-_.a-zA-Z0-9/]+/' . $install['extension'] . '.so#'); + Assert::regex($this->output, '#Install complete: [-_.a-zA-Z0-9/]+/' . preg_quote($install['extension'], '#') . '.so#'); $isExtEnabled = (new Process([self::PHP_BINARY, '-r', 'echo extension_loaded("' . $install['extension'] . '")?"yes":"no";'])) ->mustRun() @@ -538,4 +549,68 @@ public function iShouldSeeItHasFailedVerification(): void Assert::notNull($this->errorOutput); 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 + { + $this->runPieCommand(['install', 'xdebug/xdebug:3.5.3', 'derickr/quickhash']); + + $this->runPieCommand(['show', '-v']); + Assert::notNull($this->output); + preg_match('#Using pie\.json: (.+)#', $this->output, $pieJsonRegexMatches); + Assert::keyExists($pieJsonRegexMatches, 1); + $this->pieJsonFilename = $pieJsonRegexMatches[1]; + $this->pieLockFilename = str_replace('pie.json', 'pie.lock', $this->pieJsonFilename); + + $this->pieJsonContentBackup = file_get_contents($this->pieJsonFilename); + $this->pieLockContentBackup = file_get_contents($this->pieLockFilename); + + $testPieLockPath = realpath(__DIR__ . '/../assets/pie-lock'); + copy($testPieLockPath . '/pie.json', $this->pieJsonFilename); + copy($testPieLockPath . '/pie.lock', $this->pieLockFilename); + } + + #[When('I run a command to install from the lockfile')] + public function iRunACommandToInstallFromTheLockfile(): void + { + $this->runPieCommand(['install', '-v', '--from-lock']); + } + + #[Then('the extensions should have been updated to the lock')] + public function theExtensionsShouldHaveBeenUpdatedToTheLock(): void + { + $this->assertCommandSuccessful(); + + Assert::notNull($this->output); + $pieInstallOutput = $this->output; + + $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]); + + // `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'); + + // `quickhash` should have been removed (not in lockfile) + Assert::contains($pieInstallOutput, 'Removed extension derickr/quickhash:'); + Assert::keyNotExists($installedExtensionPackagesAndVersions, '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'); + + // 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']); + } } diff --git a/test/integration/Command/ShowCommandTest.php b/test/integration/Command/ShowCommandTest.php index c1d2370e..031e0009 100644 --- a/test/integration/Command/ShowCommandTest.php +++ b/test/integration/Command/ShowCommandTest.php @@ -193,4 +193,48 @@ public function testExecuteWithOnlyUnconstrainedUpdates(): void $outputString, ); } + + public function testExecuteShowsWarningWhenInstalledButNotInPieJson(): void + { + try { + $phpConfig = Process::run(['which', 'php-config']); + Assert::stringNotEmpty($phpConfig); + } catch (ProcessFailedException | InvalidArgumentException) { + self::markTestSkipped('This test can only run on systems with php-config'); + } + + $installCommand = new CommandTester(Container::testFactory()->get(InstallCommand::class)); + $installCommand->execute([ + 'requested-package-and-version' => [self::TEST_PACKAGE . ':2.0.2'], + '--with-php-config' => $phpConfig, + ]); + $installCommand->assertCommandIsSuccessful(); + + $outputString = $installCommand->getDisplay(); + + if (str_contains($outputString, 'NOT been automatically')) { + self::markTestSkipped('PIE couldn\'t automatically enable the extension'); + } + + PieJsonEditor::fromTargetPlatform( + PiePlatform\TargetPlatform::fromPhpBinaryPath( + PiePlatform\TargetPhp\PhpBinaryPath::fromPhpConfigExecutable( + $phpConfig, + ), + 1, + null, + ), + ) + ->removeRequire(self::TEST_PACKAGE); + + $this->commandTester->execute(['--with-php-config' => $phpConfig]); + $this->commandTester->assertCommandIsSuccessful(); + + $outputString = $this->commandTester->getDisplay(); + + self::assertStringMatchesFormat( + '%Aexample_pie_extension:%S (from %S asgrim/example-pie-extension:2.0.2 %S- installed but does not exist in pie.json)%A', + $outputString, + ); + } } diff --git a/test/integration/ComposerIntegration/ComposerIntegrationHandlerTest.php b/test/integration/ComposerIntegration/ComposerIntegrationHandlerTest.php new file mode 100644 index 00000000..b6836c99 --- /dev/null +++ b/test/integration/ComposerIntegration/ComposerIntegrationHandlerTest.php @@ -0,0 +1,242 @@ +createdFakeBinaryPath !== null && file_exists($this->createdFakeBinaryPath)) { + unlink($this->createdFakeBinaryPath); + } + + parent::tearDown(); + } + + public function setUp(): void + { + parent::setUp(); + + $phpBinaryPath = PhpBinaryPath::fromCurrentProcess(); + $this->targetPlatform = TargetPlatform::fromPhpBinaryPath($phpBinaryPath, null, null); + + $this->createdFakeBinaryPath = $this->extensionBinaryPath(); + file_put_contents($this->createdFakeBinaryPath, ''); + + $this->capturedOutput = new BufferedOutput(BufferedOutput::VERBOSITY_VERBOSE); + + $this->handler = Container::testFactory($this->capturedOutput)->get(ComposerIntegrationHandler::class); + } + + public function testRunInstallInvokesInstallerForNewPackage(): void + { + $composer = $this->makeComposer(self::VERSION_CURRENT); + + $this->handler->runInstall( + [$this->makeResolvedRequest(self::VERSION_CURRENT)], + $composer, + $this->targetPlatform, + false, + false, + ); + + $output = $this->capturedOutput->fetch(); + self::assertStringNotContainsString('is already installed and verified', $output); + self::assertStringNotContainsString('adding to install candidates', $output); + } + + public function testRunInstallSkipsAlreadyVerifiedPackageAtSameVersion(): void + { + PieJsonEditor::fromTargetPlatform($this->targetPlatform) + ->ensureExists() + ->addRequire(self::PACKAGE_NAME, self::VERSION_CURRENT); + $this->setUpInstalledJson(self::VERSION_CURRENT); + $this->setUpLockFile(); + + $composer = $this->makeComposer(self::VERSION_CURRENT); + + $this->handler->runInstall( + [$this->makeResolvedRequest(self::VERSION_CURRENT)], + $composer, + $this->targetPlatform, + false, + false, + ); + + $output = $this->capturedOutput->fetch(); + self::assertStringContainsString( + self::PACKAGE_NAME . ' (' . self::EXTENSION_NAME . ') is already installed and verified', + $output, + ); + self::assertStringContainsString('Nothing to install, update or remove', $output); + } + + #[RequiresOperatingSystemFamily('Linux')] + public function testRunInstallReinstallsVerifiedPackageWhenVersionDiffers(): void + { + PieJsonEditor::fromTargetPlatform($this->targetPlatform) + ->ensureExists() + ->addRequire(self::PACKAGE_NAME, self::VERSION_CURRENT); + $this->setUpInstalledJson(self::VERSION_CURRENT); + $this->setUpLockFile(); + + $composer = $this->makeComposer(self::VERSION_OTHER); + + $this->handler->runInstall( + [$this->makeResolvedRequest(self::VERSION_OTHER)], + $composer, + $this->targetPlatform, + false, + false, + ); + + $output = $this->capturedOutput->fetch(); + self::assertStringContainsString( + self::PACKAGE_NAME . ' (' . self::EXTENSION_NAME . ') is at ' . self::VERSION_CURRENT . ' but ' . self::VERSION_OTHER . ' was installed, adding to install candidates', + $output, + ); + self::assertStringNotContainsString('is already installed and verified', $output); + } + + public function testRunUninstallRemovesPackageFromPieJson(): void + { + PieJsonEditor::fromTargetPlatform($this->targetPlatform) + ->ensureExists() + ->addRequire(self::PACKAGE_NAME, self::VERSION_CURRENT); + $this->setUpInstalledJson(self::VERSION_CURRENT); + $this->setUpLockFile(); + + $composer = $this->makeComposer(self::VERSION_CURRENT); + + $this->handler->runUninstall( + [$this->makeResolvedRequest(self::VERSION_CURRENT)], + $composer, + $this->targetPlatform, + ); + + $pieJson = json_decode( + file_get_contents(Platform::getPieWorkingDirectory($this->targetPlatform) . '/pie.json'), + true, + ); + self::assertIsArray($pieJson); + self::assertArrayNotHasKey('require', $pieJson); + } + + private function extensionBinaryPath(): string + { + $isWindows = $this->targetPlatform->operatingSystem === OperatingSystem::Windows; + + return $this->targetPlatform->phpBinaryPath->extensionPath() . DIRECTORY_SEPARATOR . ($isWindows ? 'php_' : '') . self::EXTENSION_NAME . ($isWindows ? '.dll' : '.so'); + } + + /** @param non-empty-string $version */ + private function makeComposerPackage(string $version): CompletePackage + { + $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->setExtra([ + InstalledJsonMetadata::KEY_TARGET_PLATFORM_PHP_VERSION => $this->targetPlatform->phpBinaryPath->version(), + InstalledJsonMetadata::KEY_BUILT_BINARY => $extensionBinaryPath, + InstalledJsonMetadata::KEY_INSTALLED_BINARY => $extensionBinaryPath, + InstalledJsonMetadata::KEY_BINARY_CHECKSUM => hash_file('sha256', $extensionBinaryPath), + ]); + + return $package; + } + + /** @param non-empty-string $version */ + private function setUpInstalledJson(string $version): void + { + mkdir(Platform::getPieWorkingDirectory($this->targetPlatform) . '/vendor/composer', 0755, true); + + (new JsonFile(Platform::getPieWorkingDirectory($this->targetPlatform) . '/vendor/composer/installed.json'))->write([ + 'packages' => [(new ArrayDumper())->dump($this->makeComposerPackage($version))], + 'dev' => true, + 'dev-package-names' => [], + ]); + } + + private function setUpLockFile(): void + { + copy(__DIR__ . '/../../assets/pie-lock/pie.lock', Platform::getPieWorkingDirectory($this->targetPlatform) . '/pie.lock'); + } + + /** @param non-empty-string $version */ + private function makeResolvedRequest(string $version): ResolvedPackageRequest + { + return new ResolvedPackageRequest( + Package::fromComposerCompletePackage($this->makeComposerPackage($version)), + new RequestedPackageAndVersion(self::PACKAGE_NAME, $version), + ); + } + + /** @param non-empty-string $version */ + private function makeComposer(string $version): Composer + { + return PieComposerFactory::createPieComposer( + Container::testFactory($this->capturedOutput), + new PieComposerRequest( + new NullIO(), + $this->targetPlatform, + [new RequestedPackageAndVersion(self::PACKAGE_NAME, $version)], + PieOperation::Install, + [], + false, + ), + ); + } +} diff --git a/test/unit/Command/InstallCommandTest.php b/test/unit/Command/InstallCommandTest.php new file mode 100644 index 00000000..6e77cb7e --- /dev/null +++ b/test/unit/Command/InstallCommandTest.php @@ -0,0 +1,22 @@ +get(InstallCommand::class); + $definition = $command->getDefinition(); + + self::assertTrue($definition->hasOption(InstallCommand::OPTION_FROM_LOCK)); + } +} diff --git a/test/unit/DependencyResolver/FetchDependencyStatusesTest.php b/test/unit/DependencyResolver/FetchDependencyStatusesTest.php index f39ec6ad..c90a4c16 100644 --- a/test/unit/DependencyResolver/FetchDependencyStatusesTest.php +++ b/test/unit/DependencyResolver/FetchDependencyStatusesTest.php @@ -39,7 +39,7 @@ public function testNoRequiresReturnsEmptyArray(): void } /** @return array */ - public function phpVersionProvider(): array + public static function phpVersionProvider(): array { return [ '8.2.0' => ['8.2.0', '8.2.0'], diff --git a/test/unit/DependencyResolver/ResolveDependencyWithComposerTest.php b/test/unit/DependencyResolver/ResolveDependencyWithComposerTest.php index 5522eee5..0d05a425 100644 --- a/test/unit/DependencyResolver/ResolveDependencyWithComposerTest.php +++ b/test/unit/DependencyResolver/ResolveDependencyWithComposerTest.php @@ -460,7 +460,7 @@ public function testBundledExtensionCannotBeInstalledOnDevPhpVersion(): void } /** @return array */ - public function buildProvidersWithBundledExtensionWarnings(): array + public static function buildProvidersWithBundledExtensionWarnings(): array { return [ 'Docker' => ['https://github.com/docker-library/php'],