LayoutTest.php 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337
  1. <?php
  2. /**
  3. * Layout nodes integrity tests
  4. *
  5. * Copyright © Magento, Inc. All rights reserved.
  6. * See COPYING.txt for license details.
  7. */
  8. namespace Magento\Test\Integrity;
  9. /**
  10. * @SuppressWarnings(PHPMD.CouplingBetweenObjects)
  11. */
  12. class LayoutTest extends \PHPUnit\Framework\TestCase
  13. {
  14. /**
  15. * Cached lists of files
  16. *
  17. * @var array
  18. */
  19. protected static $_cachedFiles = [];
  20. public static function setUpBeforeClass()
  21. {
  22. \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->configure(
  23. ['preferences' => [\Magento\Theme\Model\Theme::class => \Magento\Theme\Model\Theme\Data::class]]
  24. );
  25. }
  26. public static function tearDownAfterClass()
  27. {
  28. self::$_cachedFiles = []; // Free memory
  29. }
  30. /**
  31. * Composes full layout xml for designated parameters
  32. *
  33. * @param \Magento\Framework\View\Design\ThemeInterface $theme
  34. * @return \Magento\Framework\View\Layout\Element
  35. */
  36. protected function _composeXml(\Magento\Framework\View\Design\ThemeInterface $theme)
  37. {
  38. /** @var \Magento\Framework\View\Layout\ProcessorInterface $layoutUpdate */
  39. $layoutUpdate = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create(
  40. \Magento\Framework\View\Layout\ProcessorInterface::class,
  41. ['theme' => $theme]
  42. );
  43. return $layoutUpdate->getFileLayoutUpdatesXml();
  44. }
  45. /**
  46. * Validate node's declared position in hierarchy and add errors to the specified array if found
  47. *
  48. * @param \SimpleXMLElement $node
  49. * @param \Magento\Framework\View\Layout\Element $xml
  50. * @param array &$errors
  51. */
  52. protected function _collectHierarchyErrors($node, $xml, &$errors)
  53. {
  54. $name = $node->getName();
  55. $refName = $node->getAttribute('type') == $node->getAttribute('parent');
  56. if ($refName) {
  57. $refNode = $xml->xpath("/layouts/{$refName}");
  58. if (!$refNode) {
  59. $errors[$name][] = "Node '{$refName}', referenced in hierarchy, does not exist";
  60. }
  61. }
  62. }
  63. /**
  64. * List all themes available in the system
  65. *
  66. * A test that uses such data provider is supposed to gather view resources in provided scope
  67. * and analyze their integrity. For example, merge and verify all layouts in this scope.
  68. *
  69. * Such tests allow to uncover complicated code integrity issues, that may emerge due to view fallback mechanism.
  70. * For example, a module layout file is overlapped by theme layout, which has mistakes.
  71. * Such mistakes can be uncovered only when to emulate this particular theme.
  72. * Also emulating "no theme" mode allows to detect inversed errors: when there is a view file with mistake
  73. * in a module, but it is overlapped by every single theme by files without mistake. Putting question of code
  74. * duplication aside, it is even more important to detect such errors, than an error in a single theme.
  75. *
  76. * @return array
  77. */
  78. public function areasAndThemesDataProvider()
  79. {
  80. $result = [];
  81. $themeCollection = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create(
  82. \Magento\Framework\View\Design\ThemeInterface::class
  83. )->getCollection();
  84. /** @var $theme \Magento\Framework\View\Design\ThemeInterface */
  85. foreach ($themeCollection as $theme) {
  86. $result[$theme->getFullPath() . ' [' . $theme->getId() . ']'] = [$theme];
  87. }
  88. return $result;
  89. }
  90. public function testHandleLabels()
  91. {
  92. $invoker = new \Magento\Framework\App\Utility\AggregateInvoker($this);
  93. $invoker(
  94. /**
  95. * @param \Magento\Framework\View\Design\ThemeInterface $theme
  96. */
  97. function (\Magento\Framework\View\Design\ThemeInterface $theme) {
  98. $xml = $this->_composeXml($theme);
  99. $xpath = '/layouts/*[@design_abstraction]';
  100. $handles = $xml->xpath($xpath) ?: [];
  101. /** @var \Magento\Framework\View\Layout\Element $node */
  102. $errors = [];
  103. foreach ($handles as $node) {
  104. if (!$node->xpath('@label')) {
  105. $nodeId = $node->getAttribute('id') ? ' id=' . $node->getAttribute('id') : '';
  106. $errors[] = $node->getName() . $nodeId;
  107. }
  108. }
  109. if ($errors) {
  110. $this->fail(
  111. 'The following handles must have label, but they don\'t have it:' . PHP_EOL . var_export(
  112. $errors,
  113. true
  114. )
  115. );
  116. }
  117. },
  118. $this->areasAndThemesDataProvider()
  119. );
  120. }
  121. public function testPageTypesDeclaration()
  122. {
  123. $invoker = new \Magento\Framework\App\Utility\AggregateInvoker($this);
  124. $invoker(
  125. /**
  126. * Check whether page types are declared only in layout update files allowed for it - base ones
  127. */
  128. function (\Magento\Framework\View\File $layout) {
  129. $content = simplexml_load_file($layout->getFilename());
  130. $this->assertEmpty(
  131. $content->xpath(\Magento\Framework\View\Model\Layout\Merge::XPATH_HANDLE_DECLARATION),
  132. "Theme layout update '" . $layout->getFilename() . "' contains page type declaration(s)"
  133. );
  134. },
  135. $this->pageTypesDeclarationDataProvider()
  136. );
  137. }
  138. /**
  139. * Get theme layout updates
  140. *
  141. * @return \Magento\Framework\View\File[]
  142. */
  143. public function pageTypesDeclarationDataProvider()
  144. {
  145. /** @var $themeUpdates \Magento\Framework\View\File\Collector\ThemeModular */
  146. $themeUpdates = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()
  147. ->create(\Magento\Framework\View\File\Collector\ThemeModular::class, ['subDir' => 'layout']);
  148. /** @var $themeUpdatesOverride \Magento\Framework\View\File\Collector\Override\ThemeModular */
  149. $themeUpdatesOverride = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()
  150. ->create(
  151. \Magento\Framework\View\File\Collector\Override\ThemeModular::class,
  152. ['subDir' => 'layout/override/theme']
  153. );
  154. /** @var $themeCollection \Magento\Theme\Model\Theme\Collection */
  155. $themeCollection = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create(
  156. \Magento\Theme\Model\Theme\Collection::class
  157. );
  158. /** @var $themeLayouts \Magento\Framework\View\File[] */
  159. $themeLayouts = [];
  160. /** @var $theme \Magento\Framework\View\Design\ThemeInterface */
  161. foreach ($themeCollection as $theme) {
  162. $themeLayouts = array_merge($themeLayouts, $themeUpdates->getFiles($theme, '*.xml'));
  163. $themeLayouts = array_merge($themeLayouts, $themeUpdatesOverride->getFiles($theme, '*.xml'));
  164. }
  165. $result = [];
  166. foreach ($themeLayouts as $layout) {
  167. $result[$layout->getFileIdentifier()] = [$layout];
  168. }
  169. return $result;
  170. }
  171. public function testOverrideBaseFiles()
  172. {
  173. $invoker = new \Magento\Framework\App\Utility\AggregateInvoker($this);
  174. $invoker(
  175. /**
  176. * Check, that for an overriding file ($themeFile) in a theme ($theme), there is a corresponding base file
  177. *
  178. * @param \Magento\Framework\View\File $themeFile
  179. * @param \Magento\Framework\View\Design\ThemeInterface $theme
  180. */
  181. function ($themeFile, $theme) {
  182. $baseFiles = self::_getCachedFiles(
  183. $theme->getArea(),
  184. \Magento\Framework\View\File\Collector\Base::class,
  185. $theme
  186. );
  187. $fileKey = $themeFile->getModule() . '/' . $themeFile->getName();
  188. $this->assertArrayHasKey(
  189. $fileKey,
  190. $baseFiles,
  191. sprintf("Could not find base file, overridden by theme file '%s'.", $themeFile->getFilename())
  192. );
  193. },
  194. $this->overrideBaseFilesDataProvider()
  195. );
  196. }
  197. public function testOverrideThemeFiles()
  198. {
  199. $invoker = new \Magento\Framework\App\Utility\AggregateInvoker($this);
  200. $invoker(
  201. /**
  202. * Check, that for an ancestor-overriding file ($themeFile) in a theme ($theme),
  203. * there is a corresponding file in that ancestor theme
  204. *
  205. * @param \Magento\Framework\View\File $themeFile
  206. * @param \Magento\Framework\View\Design\ThemeInterface $theme
  207. */
  208. function ($themeFile, $theme) {
  209. // Find an ancestor theme, where a file is to be overridden
  210. $ancestorTheme = $theme;
  211. while ($ancestorTheme = $ancestorTheme->getParentTheme()) {
  212. if ($ancestorTheme == $themeFile->getTheme()) {
  213. break;
  214. }
  215. }
  216. $this->assertNotNull(
  217. $ancestorTheme,
  218. sprintf(
  219. 'Could not find ancestor theme "%s", ' .
  220. 'its layout file is supposed to be overridden by file "%s".',
  221. $themeFile->getTheme()->getCode(),
  222. $themeFile->getFilename()
  223. )
  224. );
  225. // Search for the overridden file in the ancestor theme
  226. $ancestorFiles = self::_getCachedFiles(
  227. $ancestorTheme->getFullPath(),
  228. \Magento\Framework\View\File\Collector\ThemeModular::class,
  229. $ancestorTheme
  230. );
  231. $fileKey = $themeFile->getModule() . '/' . $themeFile->getName();
  232. $this->assertArrayHasKey(
  233. $fileKey,
  234. $ancestorFiles,
  235. sprintf(
  236. "Could not find original file in '%s' theme, overridden by file '%s'.",
  237. $themeFile->getTheme()->getCode(),
  238. $themeFile->getFilename()
  239. )
  240. );
  241. },
  242. $this->overrideThemeFilesDataProvider()
  243. );
  244. }
  245. /**
  246. * Retrieve list of cached source files
  247. *
  248. * @param string $cacheKey
  249. * @param string $sourceClass
  250. * @param \Magento\Framework\View\Design\ThemeInterface $theme
  251. * @return \Magento\Framework\View\File[]
  252. */
  253. protected static function _getCachedFiles(
  254. $cacheKey,
  255. $sourceClass,
  256. \Magento\Framework\View\Design\ThemeInterface $theme
  257. ) {
  258. if (!isset(self::$_cachedFiles[$cacheKey])) {
  259. /* @var $fileList \Magento\Framework\View\File[] */
  260. $fileList = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()
  261. ->create($sourceClass, ['subDir' => 'layout'])->getFiles($theme, '*.xml');
  262. $files = [];
  263. foreach ($fileList as $file) {
  264. $files[$file->getModule() . '/' . $file->getName()] = true;
  265. }
  266. self::$_cachedFiles[$cacheKey] = $files;
  267. }
  268. return self::$_cachedFiles[$cacheKey];
  269. }
  270. /**
  271. * @return array
  272. */
  273. public function overrideBaseFilesDataProvider()
  274. {
  275. return $this->_retrieveFilesForEveryTheme(
  276. \Magento\TestFramework\Helper\Bootstrap::getObjectManager()
  277. ->create(
  278. \Magento\Framework\View\File\Collector\Override\Base::class,
  279. ['subDir' => 'layout/override/base']
  280. )
  281. );
  282. }
  283. /**
  284. * @return array
  285. */
  286. public function overrideThemeFilesDataProvider()
  287. {
  288. return $this->_retrieveFilesForEveryTheme(
  289. \Magento\TestFramework\Helper\Bootstrap::getObjectManager()
  290. ->create(
  291. \Magento\Framework\View\File\Collector\Override\ThemeModular::class,
  292. ['subDir' => 'layout/override/theme']
  293. )
  294. );
  295. }
  296. /**
  297. * Scan all the themes in the system, for each theme retrieve list of files via $filesRetriever,
  298. * and return them as array of pairs [file, theme].
  299. *
  300. * @param \Magento\Framework\View\File\CollectorInterface $filesRetriever
  301. * @return array
  302. */
  303. protected function _retrieveFilesForEveryTheme(\Magento\Framework\View\File\CollectorInterface $filesRetriever)
  304. {
  305. $result = [];
  306. /** @var $themeCollection \Magento\Theme\Model\Theme\Collection */
  307. $themeCollection = \Magento\TestFramework\Helper\Bootstrap::getObjectManager()->create(
  308. \Magento\Theme\Model\Theme\Collection::class
  309. );
  310. /** @var $theme \Magento\Framework\View\Design\ThemeInterface */
  311. foreach ($themeCollection as $theme) {
  312. foreach ($filesRetriever->getFiles($theme, '*.xml') as $file) {
  313. $result['theme: ' . $theme->getFullPath() . ', ' . $file->getFilename()] = [$file, $theme];
  314. }
  315. }
  316. return $result;
  317. }
  318. }