Skip to content
Snippets Groups Projects
Commit 8a59f32e authored by Adam G-H's avatar Adam G-H
Browse files

Issue #3334994 by phenaproxima, tedbow, omkar.podey, Wim Leers: Add new...

Issue #3334994 by phenaproxima, tedbow, omkar.podey, Wim Leers: Add new InstalledPackagesList which does not rely on Composer API to get package info
parent 36f5ecd3
No related branches found
No related tags found
5 merge requests!989Issue #3356804 by phenaproxima: Flag a warning during status check if the...,!717Issue #3334994: Add new InstalledPackagesList which does not rely on Composer API to get package info,!685Issue #3338667: [PP-1] Add build test to test cweaganscomposer-patches end-to-end,!548Issue #3310729: Incorrect documentation link in UI in case of Process error,!106Issue #3247479: Allow LockFileValidator results to carry multiple messages, and improve their text
......@@ -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;
}
}
<?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;
}
}
<?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);
}
}
......@@ -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));
}
}
<?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'));
}
}
<?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(),
]);
}
}
<?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());
}
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment