magento2-docker/dev/tests/static/testsuite/Magento/Test/Integrity/ComposerTest.php

559 lines
20 KiB
PHP
Executable File

<?php
/**
* Copyright © Magento, Inc. All rights reserved.
* See COPYING.txt for license details.
*/
namespace Magento\Test\Integrity;
use Magento\Framework\App\Bootstrap;
use Magento\Framework\Component\ComponentRegistrar;
use Magento\Framework\Composer\MagentoComponent;
/**
* A test that enforces validity of composer.json files and any other conventions in Magento components
*/
class ComposerTest extends \PHPUnit\Framework\TestCase
{
/**
* @var string
*/
private static $root;
/**
* @var \stdClass
*/
private static $rootJson;
/**
* @var array
*/
private static $dependencies;
/**
* @var \Magento\Framework\ObjectManagerInterface
*/
private static $objectManager;
/**
* @var string[]
*/
private static $rootComposerModuleBlacklist = [];
/**
* @var string[]
*/
private static $moduleNameBlacklist;
/**
* @var string
*/
private static $magentoFrameworkLibraryName = 'magento/framework';
public static function setUpBeforeClass(): void
{
self::$root = BP;
self::$rootJson = json_decode(file_get_contents(self::$root . '/composer.json'), true);
self::$dependencies = [];
self::$objectManager = Bootstrap::create(BP, $_SERVER)->getObjectManager();
// A block can be whitelisted and thus not be required to be public
self::$rootComposerModuleBlacklist = self::getBlacklist(
__DIR__ . '/_files/blacklist/composer_root_modules*.txt'
);
self::$moduleNameBlacklist = self::getBlacklist(__DIR__ . '/_files/blacklist/composer_module_names*.txt');
}
/**
* Return aggregated blacklist
*
* @param string $pattern
* @return string[]
*/
public static function getBlacklist(string $pattern)
{
$blacklist = [];
foreach (glob($pattern) as $list) {
$blacklist[] = file($list, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
}
return array_merge([], ...$blacklist);
}
public function testValidComposerJson()
{
$invoker = new \Magento\Framework\App\Utility\AggregateInvoker($this);
$invoker(
/**
* @param string $dir
* @param string $packageType
*/
function ($dir, $packageType) {
$file = $dir . '/composer.json';
$this->assertFileExists($file);
$this->validateComposerJsonFile($dir);
$contents = file_get_contents($file);
$json = json_decode($contents);
$this->assertCodingStyle($contents);
$this->assertMagentoConventions($dir, $packageType, $json);
},
$this->validateComposerJsonDataProvider()
);
}
/**
* @return array
*/
public function validateComposerJsonDataProvider()
{
$root = BP;
$componentRegistrar = new ComponentRegistrar();
$result = [];
foreach ($componentRegistrar->getPaths(ComponentRegistrar::MODULE) as $dir) {
$result[$dir] = [$dir, 'magento2-module'];
}
foreach ($componentRegistrar->getPaths(ComponentRegistrar::LANGUAGE) as $dir) {
$result[$dir] = [$dir, 'magento2-language'];
}
foreach ($componentRegistrar->getPaths(ComponentRegistrar::THEME) as $dir) {
$result[$dir] = [$dir, 'magento2-theme'];
}
foreach ($componentRegistrar->getPaths(ComponentRegistrar::LIBRARY) as $dir) {
$result[$dir] = [$dir, 'magento2-library'];
}
$result[$root] = [$root, 'project'];
return $result;
}
/**
* Validate a composer.json under the given path
*
* @param string $path path to composer.json
*/
private function validateComposerJsonFile($path)
{
/** @var \Magento\Framework\Composer\MagentoComposerApplicationFactory $appFactory */
$appFactory = self::$objectManager->get(\Magento\Framework\Composer\MagentoComposerApplicationFactory::class);
$app = $appFactory->create();
try {
$app->runComposerCommand(['command' => 'validate'], $path);
} catch (\RuntimeException $exception) {
$this->fail($exception->getMessage());
}
}
/**
* Some of coding style conventions
*
* @param string $contents
*/
private function assertCodingStyle($contents)
{
$this->assertDoesNotMatchRegularExpression(
'/" :\s*["{]/',
$contents,
'Coding style: there should be no space before colon.'
);
$this->assertDoesNotMatchRegularExpression(
'/":["{]/',
$contents,
'Coding style: a space is necessary after colon.'
);
}
/**
* Enforce Magento-specific conventions to a composer.json file
*
* @param string $dir
* @param string $packageType
* @param \StdClass $json
* @throws \InvalidArgumentException
*/
private function assertMagentoConventions($dir, $packageType, \StdClass $json)
{
$this->assertObjectHasAttribute('name', $json);
$this->assertObjectHasAttribute('license', $json);
$this->assertObjectHasAttribute('type', $json);
$this->assertObjectHasAttribute('require', $json);
$this->assertEquals($packageType, $json->type);
if ($packageType !== 'project') {
self::$dependencies[] = $json->name;
$this->assertAutoloadRegistrar($json, $dir);
$this->assertNoMap($json);
}
switch ($packageType) {
case 'magento2-module':
$xml = simplexml_load_file("$dir/etc/module.xml");
if ($this->isVendorMagento($json->name)) {
$this->assertConsistentModuleName($xml, $json->name);
}
$this->assertDependsOnPhp($json->require);
$this->assertPhpVersionInSync($json->name, $json->require->php);
$this->assertDependsOnFramework($json->require);
$this->assertRequireInSync($json);
$this->assertAutoload($json);
$this->assertNoVersionSpecified($json);
break;
case 'magento2-language':
$this->assertMatchesRegularExpression(
'/^magento\/language\-[a-z]{2}_([a-z]{4}_)?[a-z]{2}$/',
$json->name
);
$this->assertDependsOnFramework($json->require);
$this->assertRequireInSync($json);
$this->assertNoVersionSpecified($json);
break;
case 'magento2-theme':
$this->assertMatchesRegularExpression(
'/^magento\/theme-(?:adminhtml|frontend)(\-[a-z0-9_]+)+$/',
$json->name
);
$this->assertDependsOnPhp($json->require);
$this->assertPhpVersionInSync($json->name, $json->require->php);
$this->assertDependsOnFramework($json->require);
$this->assertRequireInSync($json);
$this->assertNoVersionSpecified($json);
break;
case 'magento2-library':
$this->assertDependsOnPhp($json->require);
$this->assertMatchesRegularExpression('/^magento\/framework*/', $json->name);
$this->assertPhpVersionInSync($json->name, $json->require->php);
$this->assertRequireInSync($json);
$this->assertAutoload($json);
$this->assertNoVersionSpecified($json);
break;
case 'project':
$this->checkProject();
$this->assertNoVersionSpecified($json);
break;
default:
throw new \InvalidArgumentException("Unknown package type {$packageType}");
}
}
/**
* Checks if package vendor is Magento.
*
* @param string $packageName
* @return bool
*/
private function isVendorMagento(string $packageName): bool
{
return strpos($packageName, 'magento/') === 0;
}
/**
* Assert that component registrar is autoloaded in composer json
*
* @param \StdClass $json
* @param string $dir
*/
private function assertAutoloadRegistrar(\StdClass $json, $dir)
{
$error = 'There must be an "autoload->files" node in composer.json of each Magento component.';
$this->assertObjectHasAttribute('autoload', $json, $error);
$this->assertObjectHasAttribute('files', $json->autoload, $error);
$this->assertTrue(in_array("registration.php", $json->autoload->files), $error);
$this->assertFileExists("$dir/registration.php");
}
/**
* Version must not be specified in the root and package composer JSON files in Git.
*
* All versions are added by tools during release publication by version setter tool.
*
* @param \StdClass $json
*/
private function assertNoVersionSpecified(\StdClass $json)
{
if (!in_array($json->name, self::$rootComposerModuleBlacklist)) {
$errorMessage = 'Version must not be specified in the root and package composer JSON files in Git';
$this->assertObjectNotHasAttribute('version', $json, $errorMessage);
}
}
/**
* Assert that there is PSR-4 autoload in composer json
*
* @param \StdClass $json
*/
private function assertAutoload(\StdClass $json)
{
$errorMessage = 'There must be an "autoload->psr-4" section in composer.json of each Magento component.';
$this->assertObjectHasAttribute('autoload', $json, $errorMessage);
$this->assertObjectHasAttribute('psr-4', $json->autoload, $errorMessage);
}
/**
* Assert that there is map in specified composer json
*
* @param \StdClass $json
*/
private function assertNoMap(\StdClass $json)
{
$error = 'There is no "extra->map" node in composer.json of each Magento component.';
$this->assertObjectNotHasAttribute('extra', $json, $error);
}
/**
* Enforce package naming conventions for modules
*
* @param \SimpleXMLElement $xml
* @param string $packageName
*/
private function assertConsistentModuleName(\SimpleXMLElement $xml, $packageName)
{
if (!in_array($packageName, self::$moduleNameBlacklist)) {
$moduleName = (string)$xml->module->attributes()->name;
$expectedPackageName = $this->convertModuleToPackageName($moduleName);
$this->assertEquals(
$expectedPackageName,
$packageName,
"For the module '{$moduleName}', the expected package name is '{$expectedPackageName}'"
);
}
}
/**
* Make sure a component depends on php version
*
* @param \StdClass $json
*/
private function assertDependsOnPhp(\StdClass $json)
{
$this->assertObjectHasAttribute('php', $json, 'This component is expected to depend on certain PHP version(s)');
}
/**
* Make sure a component depends on magento/framework component
*
* @param \StdClass $json
*/
private function assertDependsOnFramework(\StdClass $json)
{
$this->assertObjectHasAttribute(
self::$magentoFrameworkLibraryName,
$json,
'This component is expected to depend on ' . self::$magentoFrameworkLibraryName
);
}
/**
* Assert that PHP versions in root composer.json and Magento component's composer.json are not out of sync
*
* @param string $name
* @param string $phpVersion
*/
private function assertPhpVersionInSync($name, $phpVersion)
{
if (isset(self::$rootJson['require']['php'])) {
$composerVersionsPattern = '{\s*\|\|?\s*}';
$rootPhpVersions = preg_split($composerVersionsPattern, self::$rootJson['require']['php']);
$modulePhpVersions = preg_split($composerVersionsPattern, $phpVersion);
$this->assertEmpty(
array_diff($rootPhpVersions, $modulePhpVersions),
"PHP version {$phpVersion} in component {$name} is inconsistent with version "
. self::$rootJson['require']['php'] . ' in root composer.json'
);
}
}
/**
* Make sure requirements of components are reflected in root composer.json
*
* @param \StdClass $json
* @return void
*/
private function assertRequireInSync(\StdClass $json)
{
if (preg_match('/magento\/project-*/', self::$rootJson['name']) == 1) {
return;
}
if (!in_array($json->name, self::$rootComposerModuleBlacklist) && isset($json->require)) {
$this->checkPackageInRootComposer($json);
}
}
/**
* Check if package is reflected in root composer.json
*
* @param \StdClass $json
* @return void
*/
private function checkPackageInRootComposer(\StdClass $json)
{
$name = $json->name;
$errors = [];
foreach (array_keys((array)$json->require) as $depName) {
if ($depName == 'magento/magento-composer-installer') {
// Magento Composer Installer is not needed for already existing components
continue;
}
if (!isset(self::$rootJson['require-dev'][$depName]) && !isset(self::$rootJson['require'][$depName])
&& !isset(self::$rootJson['replace'][$depName])) {
$errors[] = "'$name' depends on '$depName'";
}
}
if (!empty($errors)) {
$this->fail(
"The following dependencies are missing in root 'composer.json',"
. " while declared in child components.\n"
. "Consider adding them to 'require-dev' section (if needed for child components only),"
. " to 'replace' section (if they are present in the project),"
. " to 'require' section (if needed for the skeleton).\n"
. join("\n", $errors)
);
}
}
/**
* Convert a fully qualified module name to a composer package name according to conventions
*
* @param string $moduleName
* @return string
*/
private function convertModuleToPackageName($moduleName)
{
list($vendor, $name) = explode('_', $moduleName, 2);
$package = 'module';
foreach (preg_split('/([A-Z\d][a-z]*)/', $name, -1, PREG_SPLIT_DELIM_CAPTURE) as $chunk) {
$package .= $chunk ? "-{$chunk}" : '';
}
return strtolower("{$vendor}/{$package}");
}
public function testComponentPathsInRoot()
{
if (!isset(self::$rootJson['extra']) || !isset(self::$rootJson['extra']['component_paths'])) {
$this->markTestSkipped("The root composer.json file doesn't mention any extra component paths information");
}
$this->assertArrayHasKey(
'replace',
self::$rootJson,
"If there are any component paths specified, then they must be reflected in 'replace' section"
);
$flat = $this->getFlatPathsInfo(self::$rootJson['extra']['component_paths']);
foreach ($flat as $item) {
list($component, $path) = $item;
$this->assertFileExists(
self::$root . '/' . $path,
"Missing or invalid component path: {$component} -> {$path}"
);
$this->assertArrayHasKey(
$component,
self::$rootJson['replace'],
"The {$component} is specified in 'extra->component_paths', but missing in 'replace' section"
);
}
foreach (array_keys(self::$rootJson['replace']) as $replace) {
if (!MagentoComponent::matchMagentoComponent($replace)) {
$this->assertArrayHasKey(
$replace,
self::$rootJson['extra']['component_paths'],
"The {$replace} is specified in 'replace', but missing in 'extra->component_paths' section"
);
}
}
}
/**
* @param array $info
* @return array
* @throws \Exception
*/
private function getFlatPathsInfo(array $info)
{
$flat = [];
foreach ($info as $key => $element) {
if (is_string($element)) {
$flat[] = [$key, $element];
} elseif (is_array($element)) {
foreach ($element as $path) {
$flat[] = [$key, $path];
}
} else {
throw new \Exception("Unexpected element 'in extra->component_paths' section");
}
}
return $flat;
}
/**
* @return void
*/
private function checkProject()
{
sort(self::$dependencies);
$dependenciesListed = [];
if (strpos(self::$rootJson['name'], 'magento/project-') !== 0) {
$this->assertArrayHasKey(
'replace',
(array)self::$rootJson,
'No "replace" section found in root composer.json'
);
foreach (array_keys((array)self::$rootJson['replace']) as $key) {
if (MagentoComponent::matchMagentoComponent($key)) {
$dependenciesListed[] = $key;
}
}
sort($dependenciesListed);
$nonDeclaredDependencies = array_diff(
self::$dependencies,
$dependenciesListed,
self::$rootComposerModuleBlacklist
);
$nonexistentDependencies = array_diff($dependenciesListed, self::$dependencies);
$this->assertEmpty(
$nonDeclaredDependencies,
'Following dependencies are not declared in the root composer.json: '
. join(', ', $nonDeclaredDependencies)
);
$this->assertEmpty(
$nonexistentDependencies,
'Following dependencies declared in the root composer.json do not exist: '
. join(', ', $nonexistentDependencies)
);
}
}
/**
* Check the correspondence between the root composer file and magento/framework composer file.
*/
public function testConsistencyOfDeclarationsInComposerFiles()
{
if (strpos(self::$rootJson['name'], 'magento/project-') !== false) {
// The Dependency test is skipped for vendor/magento build
self::markTestSkipped(
'The build is running for composer installation. Consistency test for composer files is skipped.'
);
}
$componentRegistrar = new ComponentRegistrar();
$magentoFrameworkLibraryDir =
$componentRegistrar->getPath(ComponentRegistrar::LIBRARY, self::$magentoFrameworkLibraryName);
$magentoFrameworkComposerFile =
json_decode(
file_get_contents($magentoFrameworkLibraryDir . DIRECTORY_SEPARATOR . 'composer.json'),
true
);
$inconsistentDependencies = [];
foreach ($magentoFrameworkComposerFile['require'] as $dependency => $constraint) {
if (isset(self::$rootJson['require'][$dependency])
&& self::$rootJson['require'][$dependency] !== $constraint
) {
$inconsistentDependencies[] = $dependency;
}
}
$this->assertEmpty(
$inconsistentDependencies,
'There is a discrepancy between the declared versions of the following modules in "'
. self::$magentoFrameworkLibraryName . '" and the root composer.json: '
. implode(', ', $inconsistentDependencies)
);
}
}