diff --git a/features/uninstall-extensions.feature b/features/uninstall-extensions.feature new file mode 100644 index 0000000..67d8c2d --- /dev/null +++ b/features/uninstall-extensions.feature @@ -0,0 +1,6 @@ +Feature: Extensions can be uninstalled with PIE + + Example: The latest version of an extension can be downloaded + Given an extension was previously installed + When I run a command to uninstall an extension + Then the extension should not be installed anymore diff --git a/src/ComposerIntegration/Listeners/RemoveUnrelatedInstallOperations.php b/src/ComposerIntegration/Listeners/RemoveUnrelatedInstallOperations.php index eb7a8a4..905a276 100644 --- a/src/ComposerIntegration/Listeners/RemoveUnrelatedInstallOperations.php +++ b/src/ComposerIntegration/Listeners/RemoveUnrelatedInstallOperations.php @@ -8,6 +8,7 @@ use Composer\Composer; use Composer\DependencyResolver\Operation\InstallOperation; use Composer\DependencyResolver\Operation\OperationInterface; +use Composer\DependencyResolver\Operation\UninstallOperation; use Composer\DependencyResolver\Transaction; use Composer\Installer\InstallerEvent; use Composer\Installer\InstallerEvents; @@ -49,7 +50,7 @@ public function __invoke(InstallerEvent $installerEvent): void $newOperations = array_filter( $installerEvent->getTransaction()?->getOperations() ?? [], function (OperationInterface $operation) use ($pieOutput): bool { - if (! $operation instanceof InstallOperation) { + if (! $operation instanceof InstallOperation && ! $operation instanceof UninstallOperation) { $pieOutput->writeln( sprintf( 'Unexpected operation during installer: %s', diff --git a/src/ComposerIntegration/UninstallProcess.php b/src/ComposerIntegration/UninstallProcess.php index 06f1d82..781fd0b 100644 --- a/src/ComposerIntegration/UninstallProcess.php +++ b/src/ComposerIntegration/UninstallProcess.php @@ -8,6 +8,7 @@ use Php\Pie\DependencyResolver\Package; use Php\Pie\Installing\Ini\RemoveIniEntry; use Php\Pie\Installing\Uninstall; +use Symfony\Component\Console\Output\OutputInterface; use function array_walk; use function count; @@ -35,14 +36,23 @@ public function __invoke( $affectedIniFiles = ($this->removeIniEntry)($piePackage, $composerRequest->targetPlatform); if (count($affectedIniFiles) === 1) { - $output->writeln(sprintf('INI file "%s" was updated to remove the extension.', reset($affectedIniFiles))); + $output->writeln( + sprintf('INI file "%s" was updated to remove the extension.', reset($affectedIniFiles)), + OutputInterface::VERBOSITY_VERBOSE, + ); } elseif (count($affectedIniFiles) === 0) { - $output->writeln('No INI files were updated to remove the extension.'); + $output->writeln( + 'No INI files were updated to remove the extension.', + OutputInterface::VERBOSITY_VERBOSE, + ); } else { - $output->writeln('The following INI files were updated to remove the extnesion:'); + $output->writeln( + 'The following INI files were updated to remove the extnesion:', + OutputInterface::VERBOSITY_VERBOSE, + ); array_walk($affectedIniFiles, static fn (string $ini) => $output->writeln(' - ' . $ini)); } - ($this->uninstall)($piePackage); + $output->writeln(sprintf('👋 Removed extension: %s', ($this->uninstall)($piePackage)->filePath)); } } diff --git a/src/Installing/Ini/RemoveIniEntryWithFileGetContents.php b/src/Installing/Ini/RemoveIniEntryWithFileGetContents.php index df8579f..279a4d6 100644 --- a/src/Installing/Ini/RemoveIniEntryWithFileGetContents.php +++ b/src/Installing/Ini/RemoveIniEntryWithFileGetContents.php @@ -28,7 +28,12 @@ class RemoveIniEntryWithFileGetContents implements RemoveIniEntry /** @return list Returns a list of INI files that were updated to remove the extension */ public function __invoke(Package $package, TargetPlatform $targetPlatform): array { - $allIniFiles = [$targetPlatform->phpBinaryPath->loadedIniConfigurationFile()]; + $allIniFiles = []; + + $mainIni = $targetPlatform->phpBinaryPath->loadedIniConfigurationFile(); + if ($mainIni !== null) { + $allIniFiles[] = $mainIni; + } $additionalIniDirectory = $targetPlatform->phpBinaryPath->additionalIniDirectory(); if ($additionalIniDirectory !== null) { diff --git a/src/Installing/Uninstall.php b/src/Installing/Uninstall.php index 16ffac4..06584b0 100644 --- a/src/Installing/Uninstall.php +++ b/src/Installing/Uninstall.php @@ -4,10 +4,11 @@ namespace Php\Pie\Installing; +use Php\Pie\BinaryFile; use Php\Pie\DependencyResolver\Package; /** @internal This is not public API for PIE, so should not be depended upon unless you accept the risk of BC breaks */ interface Uninstall { - public function __invoke(Package $package): void; + public function __invoke(Package $package): BinaryFile; } diff --git a/src/Installing/UninstallUsingUnlink.php b/src/Installing/UninstallUsingUnlink.php index 2811d50..76d77a5 100644 --- a/src/Installing/UninstallUsingUnlink.php +++ b/src/Installing/UninstallUsingUnlink.php @@ -14,7 +14,7 @@ /** @internal This is not public API for PIE, so should not be depended upon unless you accept the risk of BC breaks */ class UninstallUsingUnlink implements Uninstall { - public function __invoke(Package $package): void + public function __invoke(Package $package): BinaryFile { $pieMetadata = PieInstalledJsonMetadataKeys::pieMetadataFromComposerPackage($package->composerPackage()); @@ -40,6 +40,8 @@ public function __invoke(Package $package): void $expectedBinaryFile->verify(); unlink($expectedBinaryFile->filePath); + // @todo verify the unlink worked etc, maybe permissions failed + return $expectedBinaryFile; } } diff --git a/test/behaviour/CliContext.php b/test/behaviour/CliContext.php index 31b2f9a..8f111b0 100644 --- a/test/behaviour/CliContext.php +++ b/test/behaviour/CliContext.php @@ -114,11 +114,32 @@ public function theExtensionShouldHaveBeenBuiltWithOptions(): void } #[When('I run a command to install an extension')] + #[Given('an extension was previously installed')] public function iRunACommandToInstallAnExtension(): void { $this->runPieCommand(['install', 'asgrim/example-pie-extension']); } + #[When('I run a command to uninstall an extension')] + public function iRunACommandToUninstallAnExtension(): void + { + $this->runPieCommand(['uninstall', 'asgrim/example-pie-extension']); + } + + #[Then('the extension should not be installed anymore')] + public function theExtensionShouldNotBeInstalled(): void + { + $this->assertCommandSuccessful(); + + Assert::regex($this->output, '#👋 Removed extension: [-_a-zA-Z0-9/]+/example_pie_extension.so#'); + + $isExtEnabled = (new Process([self::PHP_BINARY, '-r', 'echo extension_loaded("example_pie_extension")?"yes":"no";'])) + ->mustRun() + ->getOutput(); + + Assert::same('no', $isExtEnabled); + } + #[Then('the extension should have been installed')] public function theExtensionShouldHaveBeenInstalled(): void { diff --git a/test/unit/Installing/Ini/RemoveIniEntryWithFileGetContentsTest.php b/test/unit/Installing/Ini/RemoveIniEntryWithFileGetContentsTest.php index f658f2f..3aae85b 100644 --- a/test/unit/Installing/Ini/RemoveIniEntryWithFileGetContentsTest.php +++ b/test/unit/Installing/Ini/RemoveIniEntryWithFileGetContentsTest.php @@ -4,15 +4,123 @@ namespace Php\PieUnitTest\Installing\Ini; +use Composer\Package\CompletePackageInterface; +use Composer\Util\Filesystem; +use Php\Pie\DependencyResolver\Package; +use Php\Pie\ExtensionName; +use Php\Pie\ExtensionType; use Php\Pie\Installing\Ini\RemoveIniEntryWithFileGetContents; +use Php\Pie\Platform\Architecture; +use Php\Pie\Platform\OperatingSystem; +use Php\Pie\Platform\OperatingSystemFamily; +use Php\Pie\Platform\TargetPhp\PhpBinaryPath; +use Php\Pie\Platform\TargetPlatform; +use Php\Pie\Platform\ThreadSafetyMode; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use Webmozart\Assert\Assert; + +use function file_get_contents; +use function file_put_contents; +use function mkdir; +use function sys_get_temp_dir; +use function uniqid; + +use const DIRECTORY_SEPARATOR; #[CoversClass(RemoveIniEntryWithFileGetContents::class)] final class RemoveIniEntryWithFileGetContentsTest extends TestCase { - public function testRelevantIniFilesHaveExtensionRemoved(): void + private const INI_WITH_COMMENTED_EXTS = ";extension=foobar\n;zend_extension=foobar\n"; + private const INI_WITH_ACTIVE_EXTS = "extension=foobar\nzend_extension=foobar\n"; + + private string $iniFilePath; + + public function setUp(): void { - self::fail('to be implemented'); // @todo + parent::setUp(); + + $this->iniFilePath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('pie_remove_ini_test', true); + mkdir($this->iniFilePath); + Assert::positiveInteger(file_put_contents( + $this->iniFilePath . DIRECTORY_SEPARATOR . 'with_commented_exts.ini', + self::INI_WITH_COMMENTED_EXTS, + )); + Assert::positiveInteger(file_put_contents( + $this->iniFilePath . DIRECTORY_SEPARATOR . 'with_active_exts.ini', + self::INI_WITH_ACTIVE_EXTS, + )); + } + + public function tearDown(): void + { + parent::tearDown(); + + (new Filesystem())->remove($this->iniFilePath); + } + + /** + * @return array + * + * @psalm-suppress PossiblyUnusedMethod https://github.com/psalm/psalm-plugin-phpunit/issues/131 + */ + public function extensionTypeProvider(): array + { + return [ + 'phpModule' => [ExtensionType::PhpModule, "; extension=foobar ; removed by PIE\nzend_extension=foobar\n"], + 'zendExtension' => [ExtensionType::ZendExtension, "extension=foobar\n; zend_extension=foobar ; removed by PIE\n"], + ]; + } + + #[DataProvider('extensionTypeProvider')] + public function testRelevantIniFilesHaveExtensionRemoved(ExtensionType $extensionType, string $expectedActiveContent): void + { + $phpBinaryPath = $this->createMock(PhpBinaryPath::class); + $phpBinaryPath + ->method('loadedIniConfigurationFile') + ->willReturn(null); + $phpBinaryPath + ->method('additionalIniDirectory') + ->willReturn($this->iniFilePath); + + $package = new Package( + $this->createMock(CompletePackageInterface::class), + $extensionType, + ExtensionName::normaliseFromString('foobar'), + 'foobar/foobar', + '1.2.3', + null, + ); + + $targetPlatform = new TargetPlatform( + OperatingSystem::NonWindows, + OperatingSystemFamily::Linux, + $phpBinaryPath, + Architecture::x86_64, + ThreadSafetyMode::ThreadSafe, + 1, + null, + ); + + $affectedFiles = (new RemoveIniEntryWithFileGetContents())( + $package, + $targetPlatform, + ); + + self::assertSame( + [$this->iniFilePath . DIRECTORY_SEPARATOR . 'with_active_exts.ini'], + $affectedFiles, + ); + + self::assertSame( + self::INI_WITH_COMMENTED_EXTS, + file_get_contents($this->iniFilePath . DIRECTORY_SEPARATOR . 'with_commented_exts.ini'), + ); + + self::assertSame( + $expectedActiveContent, + file_get_contents($this->iniFilePath . DIRECTORY_SEPARATOR . 'with_active_exts.ini'), + ); } } diff --git a/test/unit/Installing/UninstallUsingUnlinkTest.php b/test/unit/Installing/UninstallUsingUnlinkTest.php index e6a8e54..8b928d1 100644 --- a/test/unit/Installing/UninstallUsingUnlinkTest.php +++ b/test/unit/Installing/UninstallUsingUnlinkTest.php @@ -4,20 +4,73 @@ namespace Php\PieUnitTest\Installing; +use Composer\Package\CompletePackageInterface; +use Php\Pie\ComposerIntegration\PieInstalledJsonMetadataKeys; +use Php\Pie\DependencyResolver\Package; +use Php\Pie\ExtensionName; +use Php\Pie\ExtensionType; +use Php\Pie\Installing\PackageMetadataMissing; use Php\Pie\Installing\UninstallUsingUnlink; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; +use function file_put_contents; +use function hash_file; +use function sys_get_temp_dir; +use function uniqid; + +use const DIRECTORY_SEPARATOR; + #[CoversClass(UninstallUsingUnlink::class)] final class UninstallUsingUnlinkTest extends TestCase { public function testMissingMetadataThrowsException(): void { - self::fail('to be implemented'); // @todo + $composerPackage = $this->createMock(CompletePackageInterface::class); + $composerPackage + ->method('getExtra') + ->willReturn([]); + + $package = new Package( + $composerPackage, + ExtensionType::PhpModule, + ExtensionName::normaliseFromString('foobar'), + 'foobar/foobar', + '1.2.3', + null, + ); + + $this->expectException(PackageMetadataMissing::class); + $this->expectExceptionMessage('PIE metadata was missing for package foobar/foobar. Missing metadata keys: pie-installed-binary, pie-installed-binary-checksum'); + (new UninstallUsingUnlink())($package); } public function testBinaryFileIsRemoved(): void { - self::fail('to be implemented'); // @todo + $testFilename = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('pie_uninstall_binary_test_', true); + file_put_contents($testFilename, 'test content'); + $testHash = hash_file('sha256', $testFilename); + + $composerPackage = $this->createMock(CompletePackageInterface::class); + $composerPackage + ->method('getExtra') + ->willReturn([ + PieInstalledJsonMetadataKeys::InstalledBinary->value => $testFilename, + PieInstalledJsonMetadataKeys::BinaryChecksum->value => $testHash, + ]); + + $package = new Package( + $composerPackage, + ExtensionType::PhpModule, + ExtensionName::normaliseFromString('foobar'), + 'foobar/foobar', + '1.2.3', + null, + ); + + $uninstalled = (new UninstallUsingUnlink())($package); + + self::assertSame($testFilename, $uninstalled->filePath); + self::assertFileDoesNotExist($testFilename); } }