diff --git a/package_manager/src/ComposerInspector.php b/package_manager/src/ComposerInspector.php index a41c14083ed90b7cc9e73a61e0080425ae1d0e58..c95c7c68c17d4356eb90ad0c6856633b115b58a2 100644 --- a/package_manager/src/ComposerInspector.php +++ b/package_manager/src/ComposerInspector.php @@ -25,6 +25,20 @@ final class ComposerInspector { */ private JsonProcessOutputCallback $jsonCallback; + /** + * An array of installed packages lists, keyed by `composer.lock` file path. + * + * @var \Drupal\package_manager\InstalledPackagesList[] + */ + private array $packageLists = []; + + /** + * The hashes of composer.lock files, keyed by directory path. + * + * @var string[] + */ + private array $lockFileHashes = []; + /** * Constructs a ComposerInspector object. * @@ -119,4 +133,85 @@ final class ComposerInspector { throw new \UnexpectedValueException('Unable to determine Composer version'); } + /** + * Returns the installed packages list. + * + * @param string $working_dir + * The working directory in which to run Composer. Should contain a + * `composer.lock` file. + * + * @return \Drupal\package_manager\InstalledPackagesList + * The installed packages list for the directory. + */ + public function getInstalledPackagesList(string $working_dir): InstalledPackagesList { + $working_dir = realpath($working_dir); + $lock_file_path = $working_dir . DIRECTORY_SEPARATOR . 'composer.lock'; + + // Computing the list of installed packages is an expensive operation, so + // only do it if the lock file has changed. + $lock_file_hash = hash_file('sha256', $lock_file_path); + if (array_key_exists($lock_file_path, $this->lockFileHashes) && $this->lockFileHashes[$lock_file_path] !== $lock_file_hash) { + unset($this->packageLists[$lock_file_path]); + } + $this->lockFileHashes[$lock_file_path] = $lock_file_hash; + + if (!isset($this->packageLists[$lock_file_path])) { + $packages_data = $this->show($working_dir); + + // The package type is not available using `composer show` for listing + // packages. To avoiding making many calls to `composer show package-name` + // load the lock file data to get the `type` key. + // @todo Remove all of this when + // https://github.com/composer/composer/pull/11340 lands and we bump our + // Composer requirement accordingly. + $lock_content = file_get_contents($lock_file_path); + $lock_data = json_decode($lock_content, TRUE, 512, JSON_THROW_ON_ERROR); + $lock_packages = array_merge($lock_data['packages'] ?? [], $lock_data['packages-dev'] ?? []); + foreach ($lock_packages as $lock_package) { + $name = $lock_package['name']; + if (isset($packages_data[$name]) && isset($lock_package['type'])) { + $packages_data[$name]['type'] = $lock_package['type']; + } + } + + $packages_data = array_map(fn (array $data) => InstalledPackage::createFromArray($data), $packages_data); + $this->packageLists[$lock_file_path] = new InstalledPackagesList($packages_data); + } + return $this->packageLists[$lock_file_path]; + } + + /** + * Gets the installed packages data from running `composer show`. + * + * @param string $working_dir + * The directory in which to run `composer show`. + * + * @return array[] + * The installed packages data, keyed by package name. + */ + private function show(string $working_dir): array { + $data = []; + $options = ['show', '--format=json', "--working-dir={$working_dir}"]; + + // We don't get package installation paths back from `composer show` unless + // we explicitly pass the --path option to it. However, for some + // inexplicable reason, that option hides *other* relevant information + // about the installed packages. So, to work around this maddening quirk, we + // call `composer show` once without the --path option, and once with it, + // then merge the results together. + $this->runner->run($options, $this->jsonCallback); + $output = $this->jsonCallback->getOutputData(); + foreach ($output['installed'] as $installed_package) { + $data[$installed_package['name']] = $installed_package; + } + + $options[] = '--path'; + $this->runner->run($options, $this->jsonCallback); + $output = $this->jsonCallback->getOutputData(); + foreach ($output['installed'] as $installed_package) { + $data[$installed_package['name']]['path'] = $installed_package['path']; + } + return $data; + } + } diff --git a/package_manager/src/InstalledPackage.php b/package_manager/src/InstalledPackage.php new file mode 100644 index 0000000000000000000000000000000000000000..4ba43cc90ccb50b878459267b20d1c02a3bedd49 --- /dev/null +++ b/package_manager/src/InstalledPackage.php @@ -0,0 +1,103 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\package_manager; + +use Drupal\Component\Serialization\Yaml; + +/** + * A value object that represents an installed Composer package. + */ +final class InstalledPackage { + + /** + * Constructs an InstalledPackage object. + * + * @param string $name + * The package name. + * @param string $version + * The package version. + * @param string|null $path + * The package path. + * @param string $type + * The package type. + */ + private function __construct( + public readonly string $name, + public readonly string $version, + public readonly ?string $path, + public readonly string $type + ) {} + + /** + * Create an installed package object from an array. + * + * @param array $data + * The package data. + * + * @return static + */ + public static function createFromArray(array $data): static { + $path = isset($data['path']) ? realpath($data['path']) : NULL; + return new static($data['name'], $data['version'], $path, $data['type']); + } + + /** + * Returns the Drupal project name for this package. + * + * This assumes that drupal.org adds a `project` key to every `.info.yml` file + * in the package, regardless of where they are in the package's directory + * structure. The package name is irrelevant except for checking that the + * vendor is `drupal`. For example, if the project key in the info file were + * `my_module`, and the package name were `drupal/whatever`, and this method + * would return `my_module`. + * + * @return string|null + * The name of the Drupal project installed by this package, or NULL if: + * - The package type is not one of `drupal-module`, `drupal-theme`, or + * `drupal-profile`. + * - The package's vendor is not `drupal`. + * - The project name could not otherwise be determined. + */ + public function getProjectName(): ?string { + // Only consider packages which are packaged by drupal.org and will be + // known to the core Update module. + $drupal_package_types = [ + 'drupal-module', + 'drupal-theme', + 'drupal-profile', + ]; + if ($this->path && str_starts_with($this->name, 'drupal/') && in_array($this->type, $drupal_package_types, TRUE)) { + return $this->scanForProjectName(); + } + return NULL; + } + + /** + * Scans a given path to determine the Drupal project name. + * + * The path will be scanned recursively for `.info.yml` files containing a + * `project` key. + * + * @return string|null + * The name of the project, as declared in the first found `.info.yml` which + * contains a `project` key, or NULL if none was found. + */ + private function scanForProjectName(): ?string { + $iterator = new \RecursiveDirectoryIterator($this->path); + $iterator = new \RecursiveIteratorIterator($iterator); + $iterator = new \RegexIterator($iterator, '/.+\.info\.yml$/', \RecursiveRegexIterator::GET_MATCH); + + foreach ($iterator as $match) { + $info = file_get_contents($match[0]); + $info = Yaml::decode($info); + + if (is_string($info['project']) && !empty($info['project'])) { + return $info['project']; + } + } + return NULL; + } + +} diff --git a/package_manager/src/InstalledPackagesList.php b/package_manager/src/InstalledPackagesList.php new file mode 100644 index 0000000000000000000000000000000000000000..70dd5adb559b38595c511ffb00bce6154f1318dd --- /dev/null +++ b/package_manager/src/InstalledPackagesList.php @@ -0,0 +1,170 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\package_manager; + +use Composer\Semver\Comparator; +use Drupal\Component\Serialization\Yaml; + +/** + * Defines a class to list installed Composer packages. + * + * This only lists the packages that were installed at the time this object was + * instantiated. If packages are added or removed later on, a new package list + * must be created to reflect those changes. + * + * @see \Drupal\package_manager\ComposerInspector::getInstalledPackagesList() + */ +final class InstalledPackagesList extends \ArrayObject { + + /** + * The statically cached names of the Drupal core packages. + * + * @var string[] + */ + private static ?array $corePackages = NULL; + + /** + * {@inheritdoc} + */ + public function append(mixed $value): never { + throw new \LogicException('Installed package lists cannot be modified.'); + } + + /** + * {@inheritdoc} + */ + public function offsetSet(mixed $key, mixed $value): never { + throw new \LogicException('Installed package lists cannot be modified.'); + } + + /** + * {@inheritdoc} + */ + public function offsetUnset(mixed $key): never { + throw new \LogicException('Installed package lists cannot be modified.'); + } + + /** + * {@inheritdoc} + */ + public function offsetGet(mixed $key): ?InstalledPackage { + // Overridden to provide a clearer return type hint. + return parent::offsetGet($key); + } + + /** + * {@inheritdoc} + */ + public function exchangeArray(mixed $array): never { + throw new \LogicException('Installed package lists cannot be modified.'); + } + + /** + * Returns the packages that are in this list, but not in another. + * + * @param self $other + * Another list of installed packages. + * + * @return static + * A list of packages which are in this list but not the other one, keyed by + * name. + */ + public function getPackagesNotIn(self $other): static { + $packages = array_diff_key($this->getArrayCopy(), $other->getArrayCopy()); + return new static($packages); + } + + /** + * Returns the packages which have a different version in another list. + * + * This compares this list with another one, and returns a list of packages + * which are present in both, but in different versions. + * + * @param self $other + * Another list of installed packages. + * + * @return static + * A list of packages which are present in both this list and the other one, + * but in different versions, keyed by name. + */ + public function getPackagesWithDifferentVersionsIn(self $other): static { + // Only compare packages that are both here and there. + $packages = array_intersect_key($this->getArrayCopy(), $other->getArrayCopy()); + + $packages = array_filter($packages, fn (InstalledPackage $p) => Comparator::notEqualTo($p->version, $other[$p->name]->version)); + return new static($packages); + } + + /** + * Returns the package name for a given Drupal project. + * + * Although it is common for the package name to match the project name (for + * example, a project name of `token` is likely part of the `drupal/token` + * package), it's not guaranteed. Therefore, in order to avoid inadvertently + * reading information about the wrong package, use this method to properly + * determine which package installs a particular Drupal project. + * + * @param string $project_name + * The name of a Drupal project. + * + * @return \Drupal\package_manager\InstalledPackage|null + * The Composer package which installs the project, or NULL if it could not + * be determined. + */ + public function getPackageByDrupalProjectName(string $project_name): ?InstalledPackage { + foreach ($this as $package) { + if ($package->getProjectName() === $project_name) { + // @todo Throw an exception if we find more than one package matching + // $project_name in https://drupal.org/i/3343463. + return $package; + } + } + return NULL; + } + + /** + * Returns the canonical names of the supported core packages. + * + * @return string[] + * The canonical list of supported core package names, as listed in + * ../core_packages.json. + */ + private static function getCorePackageList(): array { + if (self::$corePackages === NULL) { + $file = __DIR__ . '/../core_packages.yml'; + assert(file_exists($file), "$file does not exist."); + + $core_packages = file_get_contents($file); + $core_packages = Yaml::decode($core_packages); + + assert(is_array($core_packages), "$file did not contain a list of core packages."); + self::$corePackages = $core_packages; + } + return self::$corePackages; + } + + /** + * Returns a list of installed core packages. + * + * Packages returned by ::getCorePackageList() are considered core packages. + * + * @return static + * A list of the installed core packages. + */ + public function getCorePackages(): static { + $core_packages = array_intersect_key( + $this->getArrayCopy(), + array_flip(static::getCorePackageList()) + ); + + // If drupal/core-recommended is present, it supersedes drupal/core, since + // drupal/core will always be one of its direct dependencies. + if (array_key_exists('drupal/core-recommended', $core_packages)) { + unset($core_packages['drupal/core']); + } + return new static($core_packages); + } + +} diff --git a/package_manager/tests/src/Kernel/ComposerInspectorTest.php b/package_manager/tests/src/Kernel/ComposerInspectorTest.php index 112f2178840ef4359cfc37bec27ee190a2bb75ce..a96cc368604446eb85da5a827008343aad726a17 100644 --- a/package_manager/tests/src/Kernel/ComposerInspectorTest.php +++ b/package_manager/tests/src/Kernel/ComposerInspectorTest.php @@ -4,9 +4,11 @@ declare(strict_types = 1); namespace Drupal\Tests\package_manager\Kernel; +use Composer\Json\JsonFile; use Drupal\Component\Serialization\Json; use Drupal\Core\DependencyInjection\ContainerBuilder; use Drupal\KernelTests\KernelTestBase; +use Drupal\package_manager\InstalledPackage; use PhpTuf\ComposerStager\Domain\Exception\RuntimeException; /** @@ -14,12 +16,7 @@ use PhpTuf\ComposerStager\Domain\Exception\RuntimeException; * * @group package_manager */ -class ComposerInspectorTest extends KernelTestBase { - - /** - * {@inheritdoc} - */ - protected static $modules = ['update', 'package_manager']; +class ComposerInspectorTest extends PackageManagerKernelTestBase { /** * @covers ::getConfig @@ -67,4 +64,46 @@ class ComposerInspectorTest extends KernelTestBase { $container->getDefinition('package_manager.composer_inspector')->setPublic(TRUE); } + /** + * @covers ::getInstalledPackagesList + */ + public function testGetInstalledPackagesList(): void { + $project_root = $this->container->get('package_manager.path_locator') + ->getProjectRoot(); + + /** @var \Drupal\package_manager\ComposerInspector $inspector */ + $inspector = $this->container->get('package_manager.composer_inspector'); + $list = $inspector->getInstalledPackagesList($project_root); + + $this->assertInstanceOf(InstalledPackage::class, $list['drupal/core']); + $this->assertSame('drupal/core', $list['drupal/core']->name); + $this->assertSame('drupal-core', $list['drupal/core']->type); + $this->assertSame('9.8.0', $list['drupal/core']->version); + $this->assertSame("$project_root/vendor/drupal/core", $list['drupal/core']->path); + + $this->assertInstanceOf(InstalledPackage::class, $list['drupal/core-recommended']); + $this->assertSame('drupal/core-recommended', $list['drupal/core-recommended']->name); + $this->assertSame('project', $list['drupal/core-recommended']->type); + $this->assertSame('9.8.0', $list['drupal/core']->version); + $this->assertSame("$project_root/vendor/drupal/core-recommended", $list['drupal/core-recommended']->path); + + $this->assertInstanceOf(InstalledPackage::class, $list['drupal/core-dev']); + $this->assertSame('drupal/core-dev', $list['drupal/core-dev']->name); + $this->assertSame('package', $list['drupal/core-dev']->type); + $this->assertSame('9.8.0', $list['drupal/core']->version); + $this->assertSame("$project_root/vendor/drupal/core-dev", $list['drupal/core-dev']->path); + + // Since the lock file hasn't changed, we should get the same package list + // back if we call getInstalledPackageList() again. + $this->assertSame($list, $inspector->getInstalledPackagesList($project_root)); + + // If we change the lock file, we should get a different package list. + $lock_file = new JsonFile($project_root . '/composer.lock'); + $lock_data = $lock_file->read(); + $this->assertArrayHasKey('_readme', $lock_data); + unset($lock_data['_readme']); + $lock_file->write($lock_data); + $this->assertNotSame($list, $inspector->getInstalledPackagesList($project_root)); + } + } diff --git a/package_manager/tests/src/Kernel/InstalledPackagesListTest.php b/package_manager/tests/src/Kernel/InstalledPackagesListTest.php new file mode 100644 index 0000000000000000000000000000000000000000..77a2e5aaa3fdff7e6f8f07fac5020eb7ea892216 --- /dev/null +++ b/package_manager/tests/src/Kernel/InstalledPackagesListTest.php @@ -0,0 +1,124 @@ +<?php + +namespace Drupal\Tests\package_manager\Kernel; + +use Drupal\fixture_manipulator\ActiveFixtureManipulator; +use Drupal\package_manager\InstalledPackage; +use Drupal\package_manager\InstalledPackagesList; + +/** + * @coversDefaultClass \Drupal\package_manager\InstalledPackagesList + * + * @group package_manager + */ +class InstalledPackagesListTest extends PackageManagerKernelTestBase { + + /** + * @covers \Drupal\package_manager\InstalledPackage::getProjectName + * @covers ::getPackageByDrupalProjectName + */ + public function testPackageByDrupalProjectName(): void { + // In getPackageByDrupalProjectName(), we don't expect that projects will be + // in the "correct" places -- for example, we don't assume that modules will + // be in the `modules` directory, or themes will be the `themes` directory. + // So, in this test, we ensure that flexibility works by just throwing all + // the projects into a single `projects` directory. + $projects_path = $this->container->get('package_manager.path_locator') + ->getProjectRoot() . '/projects'; + + // The project name does not match the package name, and the project + // physically exists. + (new ActiveFixtureManipulator()) + ->addProjectAtPath('projects/theme_project') + ->commitChanges(); + $list = new InstalledPackagesList([ + 'drupal/a_package' => InstalledPackage::createFromArray([ + 'name' => 'drupal/a_package', + 'version' => '1.0.0', + 'type' => 'drupal-theme', + 'path' => $projects_path . '/theme_project', + ]), + ]); + $this->assertSame($list['drupal/a_package'], $list->getPackageByDrupalProjectName('theme_project')); + + // The project physically exists, but the package path points to the wrong + // place. + (new ActiveFixtureManipulator()) + ->addProjectAtPath('projects/example3') + ->commitChanges(); + $list = new InstalledPackagesList([ + 'drupal/example3' => InstalledPackage::createFromArray([ + 'name' => 'drupal/example3', + 'version' => '1.0.0', + 'type' => 'drupal-module', + // This path exists, but it doesn't contain the `example3` project. + 'path' => $projects_path . '/theme_project', + ]), + ]); + $this->assertNull($list->getPackageByDrupalProjectName('example3')); + + // The project does not physically exist. + $list = new InstalledPackagesList([ + 'drupal/missing' => InstalledPackage::createFromArray([ + 'name' => 'drupal/missing', + 'version' => '1.0.0', + 'type' => 'drupal-module', + 'path' => NULL, + ]), + ]); + $this->assertNull($list->getPackageByDrupalProjectName('missing')); + + // The project physically exists in a subdirectory of the package. + (new ActiveFixtureManipulator()) + ->addProjectAtPath('projects/grab_bag/modules/module_in_subdirectory') + ->commitChanges(); + $list = new InstalledPackagesList([ + 'drupal/grab_bag' => InstalledPackage::createFromArray([ + 'name' => 'drupal/grab_bag', + 'version' => '1.0.0', + 'type' => 'drupal-profile', + 'path' => $projects_path . '/grab_bag', + ]), + ]); + $this->assertSame($list['drupal/grab_bag'], $list->getPackageByDrupalProjectName('module_in_subdirectory')); + + // The package name matches a project that physically exists, but the + // package vendor is not `drupal`. + (new ActiveFixtureManipulator()) + ->addProjectAtPath('projects/not_from_drupal') + ->commitChanges(); + $list = new InstalledPackagesList([ + 'vendor/not_from_drupal' => InstalledPackage::createFromArray([ + 'name' => 'vendor/not_from_drupal', + 'version' => '1.0.0', + 'type' => 'drupal-module', + 'path' => $projects_path . '/not_from_drupal', + ]), + ]); + $this->assertNull($list->getPackageByDrupalProjectName('not_from_drupal')); + + // These package names match physically existing projects, and they are + // from the `drupal` vendor, but they're not supported package types. + (new ActiveFixtureManipulator()) + ->addProjectAtPath('projects/custom_module') + ->addProjectAtPath('projects/custom_theme') + ->commitChanges(); + $list = new InstalledPackagesList([ + 'drupal/custom_module' => InstalledPackage::createFromArray([ + 'name' => 'drupal/custom_module', + 'version' => '1.0.0', + 'type' => 'drupal-custom-module', + 'path' => $projects_path . '/custom_module', + ]), + 'drupal/custom_theme' => InstalledPackage::createFromArray([ + 'name' => 'drupal/custom_theme', + 'version' => '1.0.0', + 'type' => 'drupal-custom-theme', + 'path' => $projects_path . '/custom_theme', + ]), + ]); + $this->assertNull($list->getPackageByDrupalProjectName('custom_module')); + $this->assertNull($list->getPackageByDrupalProjectName('custom_theme')); + } + +} diff --git a/package_manager/tests/src/Unit/InstalledPackageTest.php b/package_manager/tests/src/Unit/InstalledPackageTest.php new file mode 100644 index 0000000000000000000000000000000000000000..e199a8b3e2b099b7f771e959a66349e909bef05c --- /dev/null +++ b/package_manager/tests/src/Unit/InstalledPackageTest.php @@ -0,0 +1,56 @@ +<?php + +namespace Drupal\Tests\package_manager\Unit; + +use Drupal\package_manager\InstalledPackage; +use Drupal\Tests\UnitTestCase; + +/** + * @coversDefaultClass \Drupal\package_manager\InstalledPackage + * + * @group package_manager + */ +class InstalledPackageTest extends UnitTestCase { + + /** + * @covers ::createFromArray + */ + public function testPathResolution(): void { + // It's okay to create an InstalledPackage without a path. + $package = InstalledPackage::createFromArray([ + 'name' => 'vendor/test', + 'type' => 'library', + 'version' => '1.0.0', + ]); + $this->assertNull($package->path); + + $package = InstalledPackage::createFromArray([ + 'name' => 'vendor/test', + 'type' => 'library', + 'version' => '1.0.0', + 'path' => NULL, + ]); + $this->assertNull($package->path); + + // Paths should be converted to real paths. + $package = InstalledPackage::createFromArray([ + 'name' => 'vendor/test', + 'type' => 'library', + 'version' => '1.0.0', + 'path' => __DIR__ . '/../..', + ]); + $this->assertSame(realpath(__DIR__ . '/../..'), $package->path); + + // If we provide a path that cannot be resolved to a real path, it should + // raise an error. + $this->expectException('TypeError'); + $this->expectExceptionMessageMatches('/must be of type \?string, bool given/'); + InstalledPackage::createFromArray([ + 'name' => 'vendor/test', + 'type' => 'library', + 'version' => '1.0.0', + 'path' => $this->getRandomGenerator()->string(), + ]); + } + +} diff --git a/package_manager/tests/src/Unit/InstalledPackagesListTest.php b/package_manager/tests/src/Unit/InstalledPackagesListTest.php new file mode 100644 index 0000000000000000000000000000000000000000..fc9c4134e2c7caa647467639308ee99875745a0c --- /dev/null +++ b/package_manager/tests/src/Unit/InstalledPackagesListTest.php @@ -0,0 +1,155 @@ +<?php + +namespace Drupal\Tests\package_manager\Unit; + +use Drupal\package_manager\InstalledPackage; +use Drupal\package_manager\InstalledPackagesList; +use Drupal\Tests\UnitTestCase; + +/** + * @coversDefaultClass \Drupal\package_manager\InstalledPackagesList + * + * @group package_manager + */ +class InstalledPackagesListTest extends UnitTestCase { + + /** + * @covers ::offsetSet + * @covers ::offsetUnset + * @covers ::append + * @covers ::exchangeArray + * + * @testWith ["offsetSet", ["new", "thing"]] + * ["offsetUnset", ["existing"]] + * ["append", ["new thing"]] + * ["exchangeArray", [{"evil": "twin"}]] + */ + public function testImmutability(string $method, array $arguments): void { + $list = new InstalledPackagesList(['existing' => 'thing']); + $this->expectException('LogicException'); + $this->expectExceptionMessage('Installed package lists cannot be modified.'); + $list->$method(...$arguments); + } + + /** + * @covers ::getPackagesNotIn + * @covers ::getPackagesWithDifferentVersionsIn + */ + public function testPackageComparison(): void { + $active = new InstalledPackagesList([ + 'drupal/existing' => InstalledPackage::createFromArray([ + 'name' => 'drupal/existing', + 'version' => '1.0.0', + 'path' => NULL, + 'type' => 'drupal-module', + ]), + 'drupal/updated' => InstalledPackage::createFromArray([ + 'name' => 'drupal/updated', + 'version' => '1.0.0', + 'path' => NULL, + 'type' => 'drupal-module', + ]), + 'drupal/removed' => InstalledPackage::createFromArray([ + 'name' => 'drupal/removed', + 'version' => '1.0.0', + 'path' => NULL, + 'type' => 'drupal-module', + ]), + ]); + $staged = new InstalledPackagesList([ + 'drupal/existing' => InstalledPackage::createFromArray([ + 'name' => 'drupal/existing', + 'version' => '1.0.0', + 'path' => NULL, + 'type' => 'drupal-module', + ]), + 'drupal/updated' => InstalledPackage::createFromArray([ + 'name' => 'drupal/updated', + 'version' => '1.1.0', + 'path' => NULL, + 'type' => 'drupal-module', + ]), + 'drupal/added' => InstalledPackage::createFromArray([ + 'name' => 'drupal/added', + 'version' => '1.0.0', + 'path' => NULL, + 'type' => 'drupal-module', + ]), + ]); + + $added = $staged->getPackagesNotIn($active)->getArrayCopy(); + $this->assertSame(['drupal/added'], array_keys($added)); + + $removed = $active->getPackagesNotIn($staged)->getArrayCopy(); + $this->assertSame(['drupal/removed'], array_keys($removed)); + + $updated = $active->getPackagesWithDifferentVersionsIn($staged)->getArrayCopy(); + $this->assertSame(['drupal/updated'], array_keys($updated)); + } + + /** + * @covers ::getCorePackages + */ + public function testCorePackages(): void { + $data = [ + 'drupal/core' => InstalledPackage::createFromArray([ + 'name' => 'drupal/core', + 'version' => \Drupal::VERSION, + 'type' => 'drupal-core', + 'path' => NULL, + ]), + 'drupal/core-dev' => InstalledPackage::createFromArray([ + 'name' => 'drupal/core-dev', + 'version' => \Drupal::VERSION, + 'type' => 'metapackage', + 'path' => NULL, + ]), + 'drupal/core-dev-pinned' => InstalledPackage::createFromArray([ + 'name' => 'drupal/core-dev-pinned', + 'version' => \Drupal::VERSION, + 'type' => 'metapackage', + 'path' => NULL, + ]), + 'drupal/core-composer-scaffold' => InstalledPackage::createFromArray([ + 'name' => 'drupal/core-composer-scaffold', + 'version' => \Drupal::VERSION, + 'type' => 'composer-plugin', + 'path' => NULL, + ]), + 'drupal/core-project-message' => [ + 'name' => 'drupal/core-project-message', + 'version' => \Drupal::VERSION, + 'type' => 'composer-plugin', + 'path' => NULL, + ], + 'drupal/core-vendor-hardening' => InstalledPackage::createFromArray([ + 'name' => 'drupal/core-vendor-hardening', + 'version' => \Drupal::VERSION, + 'type' => 'composer-plugin', + 'path' => NULL, + ]), + 'drupal/not-core' => InstalledPackage::createFromArray([ + 'name' => 'drupal/not-core', + 'version' => '1.0.0', + 'type' => 'drupal-module', + 'path' => NULL, + ]), + ]; + + $list = new InstalledPackagesList($data); + $this->assertArrayNotHasKey('drupal/not-core', $list->getCorePackages()); + + // If drupal/core-recommended is in the list, it should supersede + // drupal/core. + $this->assertArrayHasKey('drupal/core', $list->getCorePackages()); + $data['drupal/core-recommended'] = InstalledPackage::createFromArray([ + 'name' => 'drupal/core-recommended', + 'version' => \Drupal::VERSION, + 'type' => 'metapackage', + 'path' => NULL, + ]); + $list = new InstalledPackagesList($data); + $this->assertArrayNotHasKey('drupal/core', $list->getCorePackages()); + } + +}