5 * Contains \Drupal\Tests\Core\Render\RendererTest.
8 namespace Drupal\Tests\Core\Render;
10 use Drupal\Component\Render\MarkupInterface;
11 use Drupal\Core\Access\AccessResult;
12 use Drupal\Core\Access\AccessResultInterface;
13 use Drupal\Core\Cache\Cache;
14 use Drupal\Core\Render\Element;
15 use Drupal\Core\Render\Markup;
16 use Drupal\Core\Template\Attribute;
19 * @coversDefaultClass \Drupal\Core\Render\Renderer
22 class RendererTest extends RendererTestBase {
24 protected $defaultThemeVars = [
27 'languages:language_interface',
31 'max-age' => Cache::PERMANENT,
41 * @dataProvider providerTestRenderBasic
43 public function testRenderBasic($build, $expected, callable $setup_code = NULL) {
44 if (isset($setup_code)) {
45 $setup_code = $setup_code->bindTo($this);
49 if (isset($build['#markup'])) {
50 $this->assertNotInstanceOf(MarkupInterface::class, $build['#markup'], 'The #markup value is not marked safe before rendering.');
52 $render_output = $this->renderer->renderRoot($build);
53 $this->assertSame($expected, (string) $render_output);
54 if ($render_output !== '') {
55 $this->assertInstanceOf(MarkupInterface::class, $render_output, 'Output of render is marked safe.');
56 $this->assertInstanceOf(MarkupInterface::class, $build['#markup'], 'The #markup value is marked safe after rendering.');
61 * Provides a list of render arrays to test basic rendering.
65 public function providerTestRenderBasic() {
69 // Part 1: the most simplistic render arrays possible, none using #theme.
74 // Pass an empty string.
76 // Previously printed, see ::renderTwice for a more integration-like test.
81 // Printed in pre_render.
84 '#pre_render' => [[new TestCallables(), 'preRenderPrinted']]
86 // Basic #markup based renderable array.
90 // Basic #plain_text based renderable array.
92 '#plain_text' => 'foo',
94 // Mixing #plain_text and #markup based renderable array.
96 '#plain_text' => '<em>foo</em>',
98 ], '<em>foo</em>'];
99 // Safe strings in #plain_text are still escaped.
101 '#plain_text' => Markup::create('<em>foo</em>'),
102 ], '<em>foo</em>'];
103 // Renderable child element.
105 'child' => ['#markup' => 'bar'],
107 // XSS filtering test.
109 'child' => ['#markup' => "This is <script>alert('XSS')</script> test"],
110 ], "This is alert('XSS') test"];
111 // XSS filtering test.
113 'child' => ['#markup' => "This is <script>alert('XSS')</script> test", '#allowed_tags' => ['script']],
114 ], "This is <script>alert('XSS')</script> test"];
115 // XSS filtering test.
117 'child' => ['#markup' => "This is <script><em>alert('XSS')</em></script> <strong>test</strong>", '#allowed_tags' => ['em', 'strong']],
118 ], "This is <em>alert('XSS')</em> <strong>test</strong>"];
119 // Html escaping test.
121 'child' => ['#plain_text' => "This is <script><em>alert('XSS')</em></script> <strong>test</strong>"],
122 ], "This is <script><em>alert('XSS')</em></script> <strong>test</strong>"];
123 // XSS filtering by default test.
125 'child' => ['#markup' => "This is <script><em>alert('XSS')</em></script> <strong>test</strong>"],
126 ], "This is <em>alert('XSS')</em> <strong>test</strong>"];
127 // Ensure non-XSS tags are not filtered out.
129 'child' => ['#markup' => "This is <strong><script>alert('not a giraffe')</script></strong> test"],
130 ], "This is <strong>alert('not a giraffe')</strong> test"];
131 // #children set but empty, and renderable children.
134 'child' => ['#markup' => 'bar'],
136 // #children set, not empty, and renderable children. #children will be
137 // assumed oto be the rendered child elements, even though the #markup for
140 '#children' => 'foo',
141 'child' => ['#markup' => 'bar'],
143 // Ensure that content added to #markup via a #pre_render callback is safe.
146 '#pre_render' => [function($elements) {
147 $elements['#markup'] .= '<script>alert("bar");</script>';
150 ], 'fooalert("bar");'];
151 // Test #allowed_tags in combination with #markup and #pre_render.
154 '#allowed_tags' => ['script'],
155 '#pre_render' => [function($elements) {
156 $elements['#markup'] .= '<script>alert("bar");</script>';
159 ], 'foo<script>alert("bar");</script>'];
160 // Ensure output is escaped when adding content to #check_plain through
161 // a #pre_render callback.
163 '#plain_text' => 'foo',
164 '#pre_render' => [function($elements) {
165 $elements['#plain_text'] .= '<script>alert("bar");</script>';
168 ], 'foo<script>alert("bar");</script>'];
170 // Part 2: render arrays using #theme and #theme_wrappers.
173 // Tests that #theme and #theme_wrappers can co-exist on an element.
175 '#theme' => 'common_test_foo',
178 '#theme_wrappers' => ['container'],
179 '#attributes' => ['class' => ['baz']],
181 $setup_code_type_link = function() {
182 $this->setupThemeContainer();
183 $this->themeManager->expects($this->at(0))
185 ->with('common_test_foo', $this->anything())
186 ->willReturnCallback(function($theme, $vars) {
187 return $vars['#foo'] . $vars['#bar'];
190 $data[] = [$build, '<div class="baz">foobar</div>' . "\n", $setup_code_type_link];
192 // Tests that #theme_wrappers can disambiguate element attributes shared
193 // with rendering methods that build #children by using the alternate
194 // #theme_wrappers attribute override syntax.
197 '#theme_wrappers' => [
199 '#attributes' => ['class' => ['baz']],
202 '#attributes' => ['id' => 'foo'],
203 '#url' => 'https://www.drupal.org',
206 $setup_code_type_link = function() {
207 $this->setupThemeContainer();
208 $this->themeManager->expects($this->at(0))
210 ->with('link', $this->anything())
211 ->willReturnCallback(function($theme, $vars) {
212 $attributes = new Attribute(['href' => $vars['#url']] + (isset($vars['#attributes']) ? $vars['#attributes'] : []));
213 return '<a' . (string) $attributes . '>' . $vars['#title'] . '</a>';
216 $data[] = [$build, '<div class="baz"><a href="https://www.drupal.org" id="foo">bar</a></div>' . "\n", $setup_code_type_link];
218 // Tests that #theme_wrappers can disambiguate element attributes when the
219 // "base" attribute is not set for #theme.
222 '#url' => 'https://www.drupal.org',
224 '#theme_wrappers' => [
226 '#attributes' => ['class' => ['baz']],
230 $data[] = [$build, '<div class="baz"><a href="https://www.drupal.org">foo</a></div>' . "\n", $setup_code_type_link];
232 // Tests two 'container' #theme_wrappers, one using the "base" attributes
233 // and one using an override.
235 '#attributes' => ['class' => ['foo']],
236 '#theme_wrappers' => [
238 '#attributes' => ['class' => ['bar']],
243 $setup_code = function() {
244 $this->setupThemeContainer($this->any());
246 $data[] = [$build, '<div class="foo"><div class="bar"></div>' . "\n" . '</div>' . "\n", $setup_code];
248 // Tests array syntax theme hook suggestion in #theme_wrappers.
250 '#theme_wrappers' => [['container']],
251 '#attributes' => ['class' => ['foo']],
253 $setup_code = function() {
254 $this->setupThemeContainerMultiSuggestion($this->any());
256 $data[] = [$build, '<div class="foo"></div>' . "\n", $setup_code];
259 // Part 3: render arrays using #markup as a fallback for #theme hooks.
262 // Theme suggestion is not implemented, #markup should be rendered.
264 '#theme' => ['suggestionnotimplemented'],
267 $setup_code = function() {
268 $this->themeManager->expects($this->once())
270 ->with(['suggestionnotimplemented'], $this->anything())
273 $data[] = [$build, 'foo', $setup_code];
275 // Tests unimplemented theme suggestion, child #markup should be rendered.
277 '#theme' => ['suggestionnotimplemented'],
282 $setup_code = function() {
283 $this->themeManager->expects($this->once())
285 ->with(['suggestionnotimplemented'], $this->anything())
288 $data[] = [$build, 'foo', $setup_code];
290 // Tests implemented theme suggestion: #markup should not be rendered.
292 '#theme' => ['common_test_empty'],
295 $theme_function_output = $this->randomContextValue();
296 $setup_code = function() use ($theme_function_output) {
297 $this->themeManager->expects($this->once())
299 ->with(['common_test_empty'], $this->anything())
300 ->willReturn($theme_function_output);
302 $data[] = [$build, $theme_function_output, $setup_code];
304 // Tests implemented theme suggestion: children should not be rendered.
306 '#theme' => ['common_test_empty'],
311 $data[] = [$build, $theme_function_output, $setup_code];
314 // Part 4: handling of #children and child renderable elements.
317 // #theme is implemented so the values of both #children and 'child' will
318 // be ignored - it is the responsibility of the theme hook to render these
321 '#theme' => 'common_test_foo',
322 '#children' => 'baz',
323 'child' => ['#markup' => 'boo'],
325 $setup_code = function() {
326 $this->themeManager->expects($this->once())
328 ->with('common_test_foo', $this->anything())
329 ->willReturn('foobar');
331 $data[] = [$build, 'foobar', $setup_code];
333 // #theme is implemented but #render_children is TRUE. As in the case where
334 // #theme is not set, empty #children means child elements are rendered
337 '#theme' => 'common_test_foo',
339 '#render_children' => TRUE,
344 $setup_code = function() {
345 $this->themeManager->expects($this->never())
348 $data[] = [$build, 'boo', $setup_code];
350 // #theme is implemented but #render_children is TRUE. As in the case where
351 // #theme is not set, #children will take precedence over 'child'.
353 '#theme' => 'common_test_foo',
354 '#children' => 'baz',
355 '#render_children' => TRUE,
360 $setup_code = function() {
361 $this->themeManager->expects($this->never())
364 $data[] = [$build, 'baz', $setup_code];
373 public function testRenderSorting() {
374 $first = $this->randomMachineName();
375 $second = $this->randomMachineName();
376 // Build an array with '#weight' set for each element.
380 '#markup' => $second,
387 $output = $this->renderer->renderRoot($elements);
389 // The lowest weight element should appear last in $output.
390 $this->assertTrue(strpos($output, $second) > strpos($output, $first), 'Elements were sorted correctly by weight.');
392 // Confirm that the $elements array has '#sorted' set to TRUE.
393 $this->assertTrue($elements['#sorted'], "'#sorted' => TRUE was added to the array");
395 // Pass $elements through \Drupal\Core\Render\Element::children() and
396 // ensure it remains sorted in the correct order. drupal_render() will
397 // return an empty string if used on the same array in the same request.
398 $children = Element::children($elements);
399 $this->assertTrue(array_shift($children) == 'first', 'Child found in the correct order.');
400 $this->assertTrue(array_shift($children) == 'second', 'Child found in the correct order.');
407 public function testRenderSortingWithSetHashSorted() {
408 $first = $this->randomMachineName();
409 $second = $this->randomMachineName();
410 // The same array structure again, but with #sorted set to TRUE.
414 '#markup' => $second,
422 $output = $this->renderer->renderRoot($elements);
424 // The elements should appear in output in the same order as the array.
425 $this->assertTrue(strpos($output, $second) < strpos($output, $first), 'Elements were not sorted.');
432 * @dataProvider providerAccessValues
434 public function testRenderWithPresetAccess($access) {
436 '#access' => $access,
439 $this->assertAccess($build, $access);
446 * @dataProvider providerAccessValues
448 public function testRenderWithAccessCallbackCallable($access) {
450 '#access_callback' => function() use ($access) {
455 $this->assertAccess($build, $access);
459 * Ensures that the #access property wins over the callable.
464 * @dataProvider providerAccessValues
466 public function testRenderWithAccessPropertyAndCallback($access) {
468 '#access' => $access,
469 '#access_callback' => function() {
474 $this->assertAccess($build, $access);
481 * @dataProvider providerAccessValues
483 public function testRenderWithAccessControllerResolved($access) {
486 case AccessResult::allowed():
487 $method = 'accessResultAllowed';
490 case AccessResult::forbidden():
491 $method = 'accessResultForbidden';
495 $method = 'accessFalse';
499 $method = 'accessTrue';
504 '#access_callback' => 'Drupal\Tests\Core\Render\TestAccessClass::' . $method,
507 $this->assertAccess($build, $access);
514 public function testRenderAccessCacheablityDependencyInheritance() {
516 '#access' => AccessResult::allowed()->addCacheContexts(['user']),
519 $this->renderer->renderPlain($build);
521 $this->assertEquals(['languages:language_interface', 'theme', 'user'], $build['#cache']['contexts']);
525 * Tests that a first render returns the rendered output and a second doesn't.
527 * (Because of the #printed property.)
532 public function testRenderTwice() {
537 $this->assertEquals('test', $this->renderer->renderRoot($build));
538 $this->assertTrue($build['#printed']);
540 // We don't want to reprint already printed render arrays.
541 $this->assertEquals('', $this->renderer->renderRoot($build));
545 * Provides a list of both booleans.
549 public function providerAccessValues() {
553 [AccessResult::forbidden()],
554 [AccessResult::allowed()],
559 * Asserts that a render array with access checking renders correctly.
561 * @param array $build
562 * A render array with either #access or #access_callback.
563 * @param bool $access
564 * Whether the render array is accessible or not.
566 protected function assertAccess($build, $access) {
567 $sensitive_content = $this->randomContextValue();
568 $build['#markup'] = $sensitive_content;
569 if (($access instanceof AccessResultInterface && $access->isAllowed()) || $access === TRUE) {
570 $this->assertSame($sensitive_content, (string) $this->renderer->renderRoot($build));
573 $this->assertSame('', (string) $this->renderer->renderRoot($build));
577 protected function setupThemeContainer($matcher = NULL) {
578 $this->themeManager->expects($matcher ?: $this->at(1))
580 ->with('container', $this->anything())
581 ->willReturnCallback(function($theme, $vars) {
582 return '<div' . (string) (new Attribute($vars['#attributes'])) . '>' . $vars['#children'] . "</div>\n";
586 protected function setupThemeContainerMultiSuggestion($matcher = NULL) {
587 $this->themeManager->expects($matcher ?: $this->at(1))
589 ->with(['container'], $this->anything())
590 ->willReturnCallback(function($theme, $vars) {
591 return '<div' . (string) (new Attribute($vars['#attributes'])) . '>' . $vars['#children'] . "</div>\n";
599 public function testRenderWithoutThemeArguments() {
601 '#theme' => 'common_test_foo',
604 $this->themeManager->expects($this->once())
606 ->with('common_test_foo', $this->defaultThemeVars + $element)
607 ->willReturn('foobar');
609 // Test that defaults work.
610 $this->assertEquals($this->renderer->renderRoot($element), 'foobar', 'Defaults work');
617 public function testRenderWithThemeArguments() {
619 '#theme' => 'common_test_foo',
620 '#foo' => $this->randomMachineName(),
621 '#bar' => $this->randomMachineName(),
624 $this->themeManager->expects($this->once())
626 ->with('common_test_foo', $this->defaultThemeVars + $element)
627 ->willReturnCallback(function ($hook, $vars) {
628 return $vars['#foo'] . $vars['#bar'];
631 // Tests that passing arguments to the theme function works.
632 $this->assertEquals($this->renderer->renderRoot($element), $element['#foo'] . $element['#bar'], 'Passing arguments to theme functions works');
638 * @covers \Drupal\Core\Render\RenderCache::get
639 * @covers \Drupal\Core\Render\RenderCache::set
640 * @covers \Drupal\Core\Render\RenderCache::createCacheID
642 public function testRenderCache() {
643 $this->setUpRequest();
644 $this->setupMemoryCache();
646 // Create an empty element.
649 'keys' => ['render_cache_test'],
650 'tags' => ['render_cache_tag'],
655 'keys' => ['render_cache_test_child'],
656 'tags' => ['render_cache_tag_child:1', 'render_cache_tag_child:2'],
662 // Render the element and confirm that it goes through the rendering
663 // process (which will set $element['#printed']).
664 $element = $test_element;
665 $this->renderer->renderRoot($element);
666 $this->assertTrue(isset($element['#printed']), 'No cache hit');
668 // Render the element again and confirm that it is retrieved from the cache
669 // instead (so $element['#printed'] will not be set).
670 $element = $test_element;
671 $this->renderer->renderRoot($element);
672 $this->assertFalse(isset($element['#printed']), 'Cache hit');
674 // Test that cache tags are correctly collected from the render element,
675 // including the ones from its subchild.
678 'render_cache_tag_child:1',
679 'render_cache_tag_child:2',
681 $this->assertEquals($expected_tags, $element['#cache']['tags'], 'Cache tags were collected from the element and its subchild.');
683 // The cache item also has a 'rendered' cache tag.
684 $cache_item = $this->cacheFactory->get('render')->get('render_cache_test:en:stark');
685 $this->assertSame(Cache::mergeTags($expected_tags, ['rendered']), $cache_item->tags);
691 * @covers \Drupal\Core\Render\RenderCache::get
692 * @covers \Drupal\Core\Render\RenderCache::set
693 * @covers \Drupal\Core\Render\RenderCache::createCacheID
695 * @dataProvider providerTestRenderCacheMaxAge
697 public function testRenderCacheMaxAge($max_age, $is_render_cached, $render_cache_item_expire) {
698 $this->setUpRequest();
699 $this->setupMemoryCache();
703 'keys' => ['render_cache_test'],
704 'max-age' => $max_age,
708 $this->renderer->renderRoot($element);
710 $cache_item = $this->cacheFactory->get('render')->get('render_cache_test:en:stark');
711 if (!$is_render_cached) {
712 $this->assertFalse($cache_item);
715 $this->assertNotFalse($cache_item);
716 $this->assertSame($render_cache_item_expire, $cache_item->expire);
720 public function providerTestRenderCacheMaxAge() {
723 [60, TRUE, (int) $_SERVER['REQUEST_TIME'] + 60],
724 [Cache::PERMANENT, TRUE, -1],
729 * Tests that #cache_properties are properly handled.
731 * @param array $expected_results
732 * An associative array of expected results keyed by property name.
736 * @covers \Drupal\Core\Render\RenderCache::get
737 * @covers \Drupal\Core\Render\RenderCache::set
738 * @covers \Drupal\Core\Render\RenderCache::createCacheID
739 * @covers \Drupal\Core\Render\RenderCache::getCacheableRenderArray
741 * @dataProvider providerTestRenderCacheProperties
743 public function testRenderCacheProperties(array $expected_results) {
744 $this->setUpRequest();
745 $this->setupMemoryCache();
747 $element = $original = [
749 'keys' => ['render_cache_test'],
751 // Collect expected property names.
752 '#cache_properties' => array_keys(array_filter($expected_results)),
753 'child1' => ['#markup' => Markup::create('1')],
754 'child2' => ['#markup' => Markup::create('2')],
755 // Mark the value as safe.
756 '#custom_property' => Markup::create('custom_value'),
757 '#custom_property_array' => ['custom value'],
760 $this->renderer->renderRoot($element);
762 $cache = $this->cacheFactory->get('render');
763 $data = $cache->get('render_cache_test:en:stark')->data;
765 // Check that parent markup is ignored when caching children's markup.
766 $this->assertEquals($data['#markup'] === '', (bool) Element::children($data));
768 // Check that the element properties are cached as specified.
769 foreach ($expected_results as $property => $expected) {
770 $cached = !empty($data[$property]);
771 $this->assertEquals($cached, (bool) $expected);
772 // Check that only the #markup key is preserved for children.
774 $this->assertEquals($data[$property], $original[$property]);
777 // #custom_property_array can not be a safe_cache_property.
778 $safe_cache_properties = array_diff(Element::properties(array_filter($expected_results)), ['#custom_property_array']);
779 foreach ($safe_cache_properties as $cache_property) {
780 $this->assertInstanceOf(MarkupInterface::class, $data[$cache_property], "$cache_property is marked as a safe string");
785 * Data provider for ::testRenderCacheProperties().
788 * An array of associative arrays of expected results keyed by property
791 public function providerTestRenderCacheProperties() {
794 [['child1' => 0, 'child2' => 0, '#custom_property' => 0, '#custom_property_array' => 0]],
795 [['child1' => 0, 'child2' => 0, '#custom_property' => 1, '#custom_property_array' => 0]],
796 [['child1' => 0, 'child2' => 1, '#custom_property' => 0, '#custom_property_array' => 0]],
797 [['child1' => 0, 'child2' => 1, '#custom_property' => 1, '#custom_property_array' => 0]],
798 [['child1' => 1, 'child2' => 0, '#custom_property' => 0, '#custom_property_array' => 0]],
799 [['child1' => 1, 'child2' => 0, '#custom_property' => 1, '#custom_property_array' => 0]],
800 [['child1' => 1, 'child2' => 1, '#custom_property' => 0, '#custom_property_array' => 0]],
801 [['child1' => 1, 'child2' => 1, '#custom_property' => 1, '#custom_property_array' => 0]],
802 [['child1' => 1, 'child2' => 1, '#custom_property' => 1, '#custom_property_array' => 1]],
807 * @covers ::addCacheableDependency
809 * @dataProvider providerTestAddCacheableDependency
811 public function testAddCacheableDependency(array $build, $object, array $expected) {
812 $this->renderer->addCacheableDependency($build, $object);
813 $this->assertEquals($build, $expected);
816 public function providerTestAddCacheableDependency() {
818 // Empty render array, typical default cacheability.
821 new TestCacheableDependency([], [], Cache::PERMANENT),
826 'max-age' => Cache::PERMANENT,
830 // Empty render array, some cacheability.
833 new TestCacheableDependency(['user.roles'], ['foo'], Cache::PERMANENT),
836 'contexts' => ['user.roles'],
838 'max-age' => Cache::PERMANENT,
842 // Cacheable render array, some cacheability.
846 'contexts' => ['theme'],
851 new TestCacheableDependency(['user.roles'], ['foo'], Cache::PERMANENT),
854 'contexts' => ['theme', 'user.roles'],
855 'tags' => ['bar', 'foo'],
860 // Cacheable render array, no cacheability.
864 'contexts' => ['theme'],
872 'contexts' => ['theme'],
883 class TestAccessClass {
885 public static function accessTrue() {
889 public static function accessFalse() {
893 public static function accessResultAllowed() {
894 return AccessResult::allowed();
897 public static function accessResultForbidden() {
898 return AccessResult::forbidden();
903 class TestCallables {
905 public function preRenderPrinted($elements) {
906 $elements['#printed'] = TRUE;