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);
}
}