ComposerTest.php 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508
  1. <?php
  2. /**
  3. * Copyright © Magento, Inc. All rights reserved.
  4. * See COPYING.txt for license details.
  5. */
  6. namespace Magento\Test\Integrity;
  7. use Magento\Framework\App\Bootstrap;
  8. use Magento\Framework\Component\ComponentRegistrar;
  9. use Magento\Framework\Composer\MagentoComponent;
  10. /**
  11. * A test that enforces validity of composer.json files and any other conventions in Magento components
  12. */
  13. class ComposerTest extends \PHPUnit\Framework\TestCase
  14. {
  15. /**
  16. * @var string
  17. */
  18. private static $root;
  19. /**
  20. * @var \stdClass
  21. */
  22. private static $rootJson;
  23. /**
  24. * @var array
  25. */
  26. private static $dependencies;
  27. /**
  28. * @var \Magento\Framework\ObjectManagerInterface
  29. */
  30. private static $objectManager;
  31. /**
  32. * @var string[]
  33. */
  34. private static $rootComposerModuleBlacklist = [];
  35. /**
  36. * @var string[]
  37. */
  38. private static $moduleNameBlacklist;
  39. public static function setUpBeforeClass()
  40. {
  41. self::$root = BP;
  42. self::$rootJson = json_decode(file_get_contents(self::$root . '/composer.json'), true);
  43. self::$dependencies = [];
  44. self::$objectManager = Bootstrap::create(BP, $_SERVER)->getObjectManager();
  45. // A block can be whitelisted and thus not be required to be public
  46. self::$rootComposerModuleBlacklist = self::getBlacklist(
  47. __DIR__ . '/_files/blacklist/composer_root_modules*.txt'
  48. );
  49. self::$moduleNameBlacklist = self::getBlacklist(__DIR__ . '/_files/blacklist/composer_module_names*.txt');
  50. }
  51. /**
  52. * Return aggregated blacklist
  53. *
  54. * @param string $pattern
  55. * @return string[]
  56. */
  57. public static function getBlacklist(string $pattern)
  58. {
  59. $blacklist = [];
  60. foreach (glob($pattern) as $list) {
  61. $blacklist = array_merge($blacklist, file($list, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES));
  62. }
  63. return $blacklist;
  64. }
  65. public function testValidComposerJson()
  66. {
  67. $invoker = new \Magento\Framework\App\Utility\AggregateInvoker($this);
  68. $invoker(
  69. /**
  70. * @param string $dir
  71. * @param string $packageType
  72. */
  73. function ($dir, $packageType) {
  74. $file = $dir . '/composer.json';
  75. $this->assertFileExists($file);
  76. $this->validateComposerJsonFile($dir);
  77. $contents = file_get_contents($file);
  78. $json = json_decode($contents);
  79. $this->assertCodingStyle($contents);
  80. $this->assertMagentoConventions($dir, $packageType, $json);
  81. },
  82. $this->validateComposerJsonDataProvider()
  83. );
  84. }
  85. /**
  86. * @return array
  87. */
  88. public function validateComposerJsonDataProvider()
  89. {
  90. $root = BP;
  91. $componentRegistrar = new ComponentRegistrar();
  92. $result = [];
  93. foreach ($componentRegistrar->getPaths(ComponentRegistrar::MODULE) as $dir) {
  94. $result[$dir] = [$dir, 'magento2-module'];
  95. }
  96. foreach ($componentRegistrar->getPaths(ComponentRegistrar::LANGUAGE) as $dir) {
  97. $result[$dir] = [$dir, 'magento2-language'];
  98. }
  99. foreach ($componentRegistrar->getPaths(ComponentRegistrar::THEME) as $dir) {
  100. $result[$dir] = [$dir, 'magento2-theme'];
  101. }
  102. foreach ($componentRegistrar->getPaths(ComponentRegistrar::LIBRARY) as $dir) {
  103. $result[$dir] = [$dir, 'magento2-library'];
  104. }
  105. $result[$root] = [$root, 'project'];
  106. return $result;
  107. }
  108. /**
  109. * Validate a composer.json under the given path
  110. *
  111. * @param string $path path to composer.json
  112. */
  113. private function validateComposerJsonFile($path)
  114. {
  115. /** @var \Magento\Framework\Composer\MagentoComposerApplicationFactory $appFactory */
  116. $appFactory = self::$objectManager->get(\Magento\Framework\Composer\MagentoComposerApplicationFactory::class);
  117. $app = $appFactory->create();
  118. try {
  119. $app->runComposerCommand(['command' => 'validate'], $path);
  120. } catch (\RuntimeException $exception) {
  121. $this->fail($exception->getMessage());
  122. }
  123. }
  124. /**
  125. * Some of coding style conventions
  126. *
  127. * @param string $contents
  128. */
  129. private function assertCodingStyle($contents)
  130. {
  131. $this->assertNotRegExp('/" :\s*["{]/', $contents, 'Coding style: there should be no space before colon.');
  132. $this->assertNotRegExp('/":["{]/', $contents, 'Coding style: a space is necessary after colon.');
  133. }
  134. /**
  135. * Enforce Magento-specific conventions to a composer.json file
  136. *
  137. * @param string $dir
  138. * @param string $packageType
  139. * @param \StdClass $json
  140. * @throws \InvalidArgumentException
  141. */
  142. private function assertMagentoConventions($dir, $packageType, \StdClass $json)
  143. {
  144. $this->assertObjectHasAttribute('name', $json);
  145. $this->assertObjectHasAttribute('license', $json);
  146. $this->assertObjectHasAttribute('type', $json);
  147. $this->assertObjectHasAttribute('require', $json);
  148. $this->assertEquals($packageType, $json->type);
  149. if ($packageType !== 'project') {
  150. self::$dependencies[] = $json->name;
  151. $this->assertAutoloadRegistrar($json, $dir);
  152. $this->assertNoMap($json);
  153. }
  154. switch ($packageType) {
  155. case 'magento2-module':
  156. $xml = simplexml_load_file("$dir/etc/module.xml");
  157. if ($this->isVendorMagento($json->name)) {
  158. $this->assertConsistentModuleName($xml, $json->name);
  159. }
  160. $this->assertDependsOnPhp($json->require);
  161. $this->assertPhpVersionInSync($json->name, $json->require->php);
  162. $this->assertDependsOnFramework($json->require);
  163. $this->assertRequireInSync($json);
  164. $this->assertAutoload($json);
  165. $this->assertNoVersionSpecified($json);
  166. break;
  167. case 'magento2-language':
  168. $this->assertRegExp('/^magento\/language\-[a-z]{2}_([a-z]{4}_)?[a-z]{2}$/', $json->name);
  169. $this->assertDependsOnFramework($json->require);
  170. $this->assertRequireInSync($json);
  171. $this->assertNoVersionSpecified($json);
  172. break;
  173. case 'magento2-theme':
  174. $this->assertRegExp('/^magento\/theme-(?:adminhtml|frontend)(\-[a-z0-9_]+)+$/', $json->name);
  175. $this->assertDependsOnPhp($json->require);
  176. $this->assertPhpVersionInSync($json->name, $json->require->php);
  177. $this->assertDependsOnFramework($json->require);
  178. $this->assertRequireInSync($json);
  179. $this->assertNoVersionSpecified($json);
  180. break;
  181. case 'magento2-library':
  182. $this->assertDependsOnPhp($json->require);
  183. $this->assertRegExp('/^magento\/framework*/', $json->name);
  184. $this->assertPhpVersionInSync($json->name, $json->require->php);
  185. $this->assertRequireInSync($json);
  186. $this->assertAutoload($json);
  187. $this->assertNoVersionSpecified($json);
  188. break;
  189. case 'project':
  190. $this->checkProject();
  191. $this->assertNoVersionSpecified($json);
  192. break;
  193. default:
  194. throw new \InvalidArgumentException("Unknown package type {$packageType}");
  195. }
  196. }
  197. /**
  198. * Checks if package vendor is Magento.
  199. *
  200. * @param string $packageName
  201. * @return bool
  202. */
  203. private function isVendorMagento(string $packageName): bool
  204. {
  205. return strpos($packageName, 'magento/') === 0;
  206. }
  207. /**
  208. * Assert that component registrar is autoloaded in composer json
  209. *
  210. * @param \StdClass $json
  211. * @param string $dir
  212. */
  213. private function assertAutoloadRegistrar(\StdClass $json, $dir)
  214. {
  215. $error = 'There must be an "autoload->files" node in composer.json of each Magento component.';
  216. $this->assertObjectHasAttribute('autoload', $json, $error);
  217. $this->assertObjectHasAttribute('files', $json->autoload, $error);
  218. $this->assertTrue(in_array("registration.php", $json->autoload->files), $error);
  219. $this->assertFileExists("$dir/registration.php");
  220. }
  221. /**
  222. * Version must not be specified in the root and package composer JSON files in Git.
  223. *
  224. * All versions are added by tools during release publication by version setter tool.
  225. *
  226. * @param \StdClass $json
  227. */
  228. private function assertNoVersionSpecified(\StdClass $json)
  229. {
  230. $errorMessage = 'Version must not be specified in the root and package composer JSON files in Git';
  231. $this->assertObjectNotHasAttribute('version', $json, $errorMessage);
  232. }
  233. /**
  234. * Assert that there is PSR-4 autoload in composer json
  235. *
  236. * @param \StdClass $json
  237. */
  238. private function assertAutoload(\StdClass $json)
  239. {
  240. $errorMessage = 'There must be an "autoload->psr-4" section in composer.json of each Magento component.';
  241. $this->assertObjectHasAttribute('autoload', $json, $errorMessage);
  242. $this->assertObjectHasAttribute('psr-4', $json->autoload, $errorMessage);
  243. }
  244. /**
  245. * Assert that there is map in specified composer json
  246. *
  247. * @param \StdClass $json
  248. */
  249. private function assertNoMap(\StdClass $json)
  250. {
  251. $error = 'There is no "extra->map" node in composer.json of each Magento component.';
  252. $this->assertObjectNotHasAttribute('extra', $json, $error);
  253. }
  254. /**
  255. * Enforce package naming conventions for modules
  256. *
  257. * @param \SimpleXMLElement $xml
  258. * @param string $packageName
  259. */
  260. private function assertConsistentModuleName(\SimpleXMLElement $xml, $packageName)
  261. {
  262. if (!in_array($packageName, self::$moduleNameBlacklist)) {
  263. $moduleName = (string)$xml->module->attributes()->name;
  264. $expectedPackageName = $this->convertModuleToPackageName($moduleName);
  265. $this->assertEquals(
  266. $expectedPackageName,
  267. $packageName,
  268. "For the module '{$moduleName}', the expected package name is '{$expectedPackageName}'"
  269. );
  270. }
  271. }
  272. /**
  273. * Make sure a component depends on php version
  274. *
  275. * @param \StdClass $json
  276. */
  277. private function assertDependsOnPhp(\StdClass $json)
  278. {
  279. $this->assertObjectHasAttribute('php', $json, 'This component is expected to depend on certain PHP version(s)');
  280. }
  281. /**
  282. * Make sure a component depends on magento/framework component
  283. *
  284. * @param \StdClass $json
  285. */
  286. private function assertDependsOnFramework(\StdClass $json)
  287. {
  288. $this->assertObjectHasAttribute(
  289. 'magento/framework',
  290. $json,
  291. 'This component is expected to depend on magento/framework'
  292. );
  293. }
  294. /**
  295. * Assert that PHP versions in root composer.json and Magento component's composer.json are not out of sync
  296. *
  297. * @param string $name
  298. * @param string $phpVersion
  299. */
  300. private function assertPhpVersionInSync($name, $phpVersion)
  301. {
  302. if (isset(self::$rootJson['require']['php'])) {
  303. if ($this->isVendorMagento($name)) {
  304. $this->assertEquals(
  305. self::$rootJson['require']['php'],
  306. $phpVersion,
  307. "PHP version {$phpVersion} in component {$name} is inconsistent with version "
  308. . self::$rootJson['require']['php'] . ' in root composer.json'
  309. );
  310. } else {
  311. $composerVersionsPattern = '{\s*\|\|?\s*}';
  312. $rootPhpVersions = preg_split($composerVersionsPattern, self::$rootJson['require']['php']);
  313. $modulePhpVersions = preg_split($composerVersionsPattern, $phpVersion);
  314. $this->assertEmpty(
  315. array_diff($rootPhpVersions, $modulePhpVersions),
  316. "PHP version {$phpVersion} in component {$name} is inconsistent with version "
  317. . self::$rootJson['require']['php'] . ' in root composer.json'
  318. );
  319. }
  320. }
  321. }
  322. /**
  323. * Make sure requirements of components are reflected in root composer.json
  324. *
  325. * @param \StdClass $json
  326. * @return void
  327. */
  328. private function assertRequireInSync(\StdClass $json)
  329. {
  330. if (preg_match('/magento\/project-*/', self::$rootJson['name']) == 1) {
  331. return;
  332. }
  333. if (!in_array($json->name, self::$rootComposerModuleBlacklist) && isset($json->require)) {
  334. $this->checkPackageInRootComposer($json);
  335. }
  336. }
  337. /**
  338. * Check if package is reflected in root composer.json
  339. *
  340. * @param \StdClass $json
  341. * @return void
  342. */
  343. private function checkPackageInRootComposer(\StdClass $json)
  344. {
  345. $name = $json->name;
  346. $errors = [];
  347. foreach (array_keys((array)$json->require) as $depName) {
  348. if ($depName == 'magento/magento-composer-installer') {
  349. // Magento Composer Installer is not needed for already existing components
  350. continue;
  351. }
  352. if (!isset(self::$rootJson['require-dev'][$depName]) && !isset(self::$rootJson['require'][$depName])
  353. && !isset(self::$rootJson['replace'][$depName])) {
  354. $errors[] = "'$name' depends on '$depName'";
  355. }
  356. }
  357. if (!empty($errors)) {
  358. $this->fail(
  359. "The following dependencies are missing in root 'composer.json',"
  360. . " while declared in child components.\n"
  361. . "Consider adding them to 'require-dev' section (if needed for child components only),"
  362. . " to 'replace' section (if they are present in the project),"
  363. . " to 'require' section (if needed for the skeleton).\n"
  364. . join("\n", $errors)
  365. );
  366. }
  367. }
  368. /**
  369. * Convert a fully qualified module name to a composer package name according to conventions
  370. *
  371. * @param string $moduleName
  372. * @return string
  373. */
  374. private function convertModuleToPackageName($moduleName)
  375. {
  376. list($vendor, $name) = explode('_', $moduleName, 2);
  377. $package = 'module';
  378. foreach (preg_split('/([A-Z\d][a-z]*)/', $name, -1, PREG_SPLIT_DELIM_CAPTURE) as $chunk) {
  379. $package .= $chunk ? "-{$chunk}" : '';
  380. }
  381. return strtolower("{$vendor}/{$package}");
  382. }
  383. public function testComponentPathsInRoot()
  384. {
  385. if (!isset(self::$rootJson['extra']) || !isset(self::$rootJson['extra']['component_paths'])) {
  386. $this->markTestSkipped("The root composer.json file doesn't mention any extra component paths information");
  387. }
  388. $this->assertArrayHasKey(
  389. 'replace',
  390. self::$rootJson,
  391. "If there are any component paths specified, then they must be reflected in 'replace' section"
  392. );
  393. $flat = $this->getFlatPathsInfo(self::$rootJson['extra']['component_paths']);
  394. foreach ($flat as $item) {
  395. list($component, $path) = $item;
  396. $this->assertFileExists(
  397. self::$root . '/' . $path,
  398. "Missing or invalid component path: {$component} -> {$path}"
  399. );
  400. $this->assertArrayHasKey(
  401. $component,
  402. self::$rootJson['replace'],
  403. "The {$component} is specified in 'extra->component_paths', but missing in 'replace' section"
  404. );
  405. }
  406. foreach (array_keys(self::$rootJson['replace']) as $replace) {
  407. if (!MagentoComponent::matchMagentoComponent($replace)) {
  408. $this->assertArrayHasKey(
  409. $replace,
  410. self::$rootJson['extra']['component_paths'],
  411. "The {$replace} is specified in 'replace', but missing in 'extra->component_paths' section"
  412. );
  413. }
  414. }
  415. }
  416. /**
  417. * @param array $info
  418. * @return array
  419. * @throws \Exception
  420. */
  421. private function getFlatPathsInfo(array $info)
  422. {
  423. $flat = [];
  424. foreach ($info as $key => $element) {
  425. if (is_string($element)) {
  426. $flat[] = [$key, $element];
  427. } elseif (is_array($element)) {
  428. foreach ($element as $path) {
  429. $flat[] = [$key, $path];
  430. }
  431. } else {
  432. throw new \Exception("Unexpected element 'in extra->component_paths' section");
  433. }
  434. }
  435. return $flat;
  436. }
  437. /**
  438. * @return void
  439. */
  440. private function checkProject()
  441. {
  442. sort(self::$dependencies);
  443. $dependenciesListed = [];
  444. if (strpos(self::$rootJson['name'], 'magento/project-') !== 0) {
  445. $this->assertArrayHasKey(
  446. 'replace',
  447. (array)self::$rootJson,
  448. 'No "replace" section found in root composer.json'
  449. );
  450. foreach (array_keys((array)self::$rootJson['replace']) as $key) {
  451. if (MagentoComponent::matchMagentoComponent($key)) {
  452. $dependenciesListed[] = $key;
  453. }
  454. }
  455. sort($dependenciesListed);
  456. $nonDeclaredDependencies = array_diff(
  457. self::$dependencies,
  458. $dependenciesListed,
  459. self::$rootComposerModuleBlacklist
  460. );
  461. $nonexistentDependencies = array_diff($dependenciesListed, self::$dependencies);
  462. $this->assertEmpty(
  463. $nonDeclaredDependencies,
  464. 'Following dependencies are not declared in the root composer.json: '
  465. . join(', ', $nonDeclaredDependencies)
  466. );
  467. $this->assertEmpty(
  468. $nonexistentDependencies,
  469. 'Following dependencies declared in the root composer.json do not exist: '
  470. . join(', ', $nonexistentDependencies)
  471. );
  472. }
  473. }
  474. }