1<?php
2
3namespace Drupal\Tests\language\Functional;
4
5use Drupal\language\Entity\ConfigurableLanguage;
6use Drupal\language\Plugin\LanguageNegotiation\LanguageNegotiationUrl;
7use Drupal\menu_link_content\Entity\MenuLinkContent;
8use Drupal\Core\Language\LanguageInterface;
9use Drupal\Tests\BrowserTestBase;
10
11/**
12 * Functional tests for the language switching feature.
13 *
14 * @group language
15 */
16class LanguageSwitchingTest extends BrowserTestBase {
17
18  /**
19   * Modules to enable.
20   *
21   * @var array
22   */
23  protected static $modules = [
24    'locale',
25    'locale_test',
26    'language',
27    'block',
28    'language_test',
29    'menu_ui',
30  ];
31
32  /**
33   * {@inheritdoc}
34   */
35  protected $defaultTheme = 'classy';
36
37  protected function setUp(): void {
38    parent::setUp();
39
40    // Create and log in user.
41    $admin_user = $this->drupalCreateUser([
42      'administer blocks',
43      'administer languages',
44      'access administration pages',
45    ]);
46    $this->drupalLogin($admin_user);
47  }
48
49  /**
50   * Functional tests for the language switcher block.
51   */
52  public function testLanguageBlock() {
53    // Add language.
54    $edit = [
55      'predefined_langcode' => 'fr',
56    ];
57    $this->drupalGet('admin/config/regional/language/add');
58    $this->submitForm($edit, 'Add language');
59
60    // Set the native language name.
61    $this->saveNativeLanguageName('fr', 'français');
62
63    // Enable URL language detection and selection.
64    $edit = ['language_interface[enabled][language-url]' => '1'];
65    $this->drupalGet('admin/config/regional/language/detection');
66    $this->submitForm($edit, 'Save settings');
67
68    // Enable the language switching block.
69    $block = $this->drupalPlaceBlock('language_block:' . LanguageInterface::TYPE_INTERFACE, [
70      'id' => 'test_language_block',
71      // Ensure a 2-byte UTF-8 sequence is in the tested output.
72      'label' => $this->randomMachineName(8) . '×',
73    ]);
74
75    $this->doTestLanguageBlockAuthenticated($block->label());
76    $this->doTestLanguageBlockAnonymous($block->label());
77  }
78
79  /**
80   * For authenticated users, the "active" class is set by JavaScript.
81   *
82   * @param string $block_label
83   *   The label of the language switching block.
84   *
85   * @see self::testLanguageBlock()
86   */
87  protected function doTestLanguageBlockAuthenticated($block_label) {
88    // Assert that the language switching block is displayed on the frontpage.
89    $this->drupalGet('');
90    $this->assertSession()->pageTextContains($block_label);
91
92    // Assert that each list item and anchor element has the appropriate data-
93    // attributes.
94    $language_switchers = $this->xpath('//div[@id=:id]/ul/li', [':id' => 'block-test-language-block']);
95    $list_items = [];
96    $anchors = [];
97    $labels = [];
98    foreach ($language_switchers as $list_item) {
99      $classes = explode(" ", $list_item->getAttribute('class'));
100      list($langcode) = array_intersect($classes, ['en', 'fr']);
101      $list_items[] = [
102        'langcode_class' => $langcode,
103        'data-drupal-link-system-path' => $list_item->getAttribute('data-drupal-link-system-path'),
104      ];
105
106      $link = $list_item->find('xpath', 'a');
107      $anchors[] = [
108         'hreflang' => $link->getAttribute('hreflang'),
109         'data-drupal-link-system-path' => $link->getAttribute('data-drupal-link-system-path'),
110      ];
111      $labels[] = $link->getText();
112    }
113    $expected_list_items = [
114      0 => ['langcode_class' => 'en', 'data-drupal-link-system-path' => 'user/2'],
115      1 => ['langcode_class' => 'fr', 'data-drupal-link-system-path' => 'user/2'],
116    ];
117    $this->assertSame($expected_list_items, $list_items, 'The list items have the correct attributes that will allow the drupal.active-link library to mark them as active.');
118    $expected_anchors = [
119      0 => ['hreflang' => 'en', 'data-drupal-link-system-path' => 'user/2'],
120      1 => ['hreflang' => 'fr', 'data-drupal-link-system-path' => 'user/2'],
121    ];
122    $this->assertSame($expected_anchors, $anchors, 'The anchors have the correct attributes that will allow the drupal.active-link library to mark them as active.');
123    $settings = $this->getDrupalSettings();
124    $this->assertSame('user/2', $settings['path']['currentPath'], 'drupalSettings.path.currentPath is set correctly to allow drupal.active-link to mark the correct links as active.');
125    $this->assertFalse($settings['path']['isFront'], 'drupalSettings.path.isFront is set correctly to allow drupal.active-link to mark the correct links as active.');
126    $this->assertSame('en', $settings['path']['currentLanguage'], 'drupalSettings.path.currentLanguage is set correctly to allow drupal.active-link to mark the correct links as active.');
127    $this->assertSame(['English', 'français'], $labels, 'The language links labels are in their own language on the language switcher block.');
128  }
129
130  /**
131   * For anonymous users, the "active" class is set by PHP.
132   *
133   * @param string $block_label
134   *   The label of the language switching block.
135   *
136   * @see self::testLanguageBlock()
137   */
138  protected function doTestLanguageBlockAnonymous($block_label) {
139    $this->drupalLogout();
140
141    // Assert that the language switching block is displayed on the frontpage
142    // and ensure that the active class is added when query params are present.
143    $this->drupalGet('', ['query' => ['foo' => 'bar']]);
144    $this->assertSession()->pageTextContains($block_label);
145
146    // Assert that only the current language is marked as active.
147    $language_switchers = $this->xpath('//div[@id=:id]/ul/li', [':id' => 'block-test-language-block']);
148    $links = [
149      'active' => [],
150      'inactive' => [],
151    ];
152    $anchors = [
153      'active' => [],
154      'inactive' => [],
155    ];
156    $labels = [];
157    foreach ($language_switchers as $list_item) {
158      $classes = explode(" ", $list_item->getAttribute('class'));
159      list($langcode) = array_intersect($classes, ['en', 'fr']);
160      if (in_array('is-active', $classes)) {
161        $links['active'][] = $langcode;
162      }
163      else {
164        $links['inactive'][] = $langcode;
165      }
166
167      $link = $list_item->find('xpath', 'a');
168      $anchor_classes = explode(" ", $link->getAttribute('class'));
169      if (in_array('is-active', $anchor_classes)) {
170        $anchors['active'][] = $langcode;
171      }
172      else {
173        $anchors['inactive'][] = $langcode;
174      }
175      $labels[] = $link->getText();
176    }
177    $this->assertSame(['active' => ['en'], 'inactive' => ['fr']], $links, 'Only the current language list item is marked as active on the language switcher block.');
178    $this->assertSame(['active' => ['en'], 'inactive' => ['fr']], $anchors, 'Only the current language anchor is marked as active on the language switcher block.');
179    $this->assertSame(['English', 'français'], $labels, 'The language links labels are in their own language on the language switcher block.');
180  }
181
182  /**
183   * Tests language switcher links for domain based negotiation.
184   */
185  public function testLanguageBlockWithDomain() {
186    // Add the Italian language.
187    ConfigurableLanguage::createFromLangcode('it')->save();
188
189    // Rebuild the container so that the new language is picked up by services
190    // that hold a list of languages.
191    $this->rebuildContainer();
192
193    $languages = $this->container->get('language_manager')->getLanguages();
194
195    // Enable browser and URL language detection.
196    $edit = [
197      'language_interface[enabled][language-url]' => TRUE,
198      'language_interface[weight][language-url]' => -10,
199    ];
200    $this->drupalGet('admin/config/regional/language/detection');
201    $this->submitForm($edit, 'Save settings');
202
203    // Do not allow blank domain.
204    $edit = [
205      'language_negotiation_url_part' => LanguageNegotiationUrl::CONFIG_DOMAIN,
206      'domain[en]' => '',
207    ];
208    $this->drupalGet('admin/config/regional/language/detection/url');
209    $this->submitForm($edit, 'Save configuration');
210    $this->assertSession()->pageTextContains('The domain may not be left blank for English');
211
212    // Change the domain for the Italian language.
213    $edit = [
214      'language_negotiation_url_part' => LanguageNegotiationUrl::CONFIG_DOMAIN,
215      'domain[en]' => \Drupal::request()->getHost(),
216      'domain[it]' => 'it.example.com',
217    ];
218    $this->drupalGet('admin/config/regional/language/detection/url');
219    $this->submitForm($edit, 'Save configuration');
220    $this->assertSession()->pageTextContains('The configuration options have been saved');
221
222    // Enable the language switcher block.
223    $this->drupalPlaceBlock('language_block:' . LanguageInterface::TYPE_INTERFACE, ['id' => 'test_language_block']);
224
225    $this->drupalGet('');
226
227    /** @var \Drupal\Core\Routing\UrlGenerator $generator */
228    $generator = $this->container->get('url_generator');
229
230    // Verify the English URL is correct
231    $english_url = $generator->generateFromRoute('entity.user.canonical', ['user' => 2], ['language' => $languages['en']]);
232    $this->assertSession()->elementAttributeContains('xpath', '//div[@id="block-test-language-block"]/ul/li/a[@hreflang="en"]', 'href', $english_url);
233
234    // Verify the Italian URL is correct
235    $italian_url = $generator->generateFromRoute('entity.user.canonical', ['user' => 2], ['language' => $languages['it']]);
236    $this->assertSession()->elementAttributeContains('xpath', '//div[@id="block-test-language-block"]/ul/li/a[@hreflang="it"]', 'href', $italian_url);
237  }
238
239  /**
240   * Tests active class on links when switching languages.
241   */
242  public function testLanguageLinkActiveClass() {
243    // Add language.
244    $edit = [
245      'predefined_langcode' => 'fr',
246    ];
247    $this->drupalGet('admin/config/regional/language/add');
248    $this->submitForm($edit, 'Add language');
249
250    // Enable URL language detection and selection.
251    $edit = ['language_interface[enabled][language-url]' => '1'];
252    $this->drupalGet('admin/config/regional/language/detection');
253    $this->submitForm($edit, 'Save settings');
254
255    $this->doTestLanguageLinkActiveClassAuthenticated();
256    $this->doTestLanguageLinkActiveClassAnonymous();
257  }
258
259  /**
260   * Check the path-admin class, as same as on default language.
261   */
262  public function testLanguageBodyClass() {
263    $searched_class = 'path-admin';
264
265    // Add language.
266    $edit = [
267      'predefined_langcode' => 'fr',
268    ];
269    $this->drupalGet('admin/config/regional/language/add');
270    $this->submitForm($edit, 'Add language');
271
272    // Enable URL language detection and selection.
273    $edit = ['language_interface[enabled][language-url]' => '1'];
274    $this->drupalGet('admin/config/regional/language/detection');
275    $this->submitForm($edit, 'Save settings');
276
277    // Check if the default (English) admin/config page has the right class.
278    $this->drupalGet('admin/config');
279    $class = $this->xpath('//body[contains(@class, :class)]', [':class' => $searched_class]);
280    $this->assertTrue(isset($class[0]), 'The path-admin class appears on default language.');
281
282    // Check if the French admin/config page has the right class.
283    $this->drupalGet('fr/admin/config');
284    $class = $this->xpath('//body[contains(@class, :class)]', [':class' => $searched_class]);
285    $this->assertTrue(isset($class[0]), 'The path-admin class same as on default language.');
286
287    // The testing profile sets the user/login page as the frontpage. That
288    // redirects authenticated users to their profile page, so check with an
289    // anonymous user instead.
290    $this->drupalLogout();
291
292    // Check if the default (English) frontpage has the right class.
293    $this->drupalGet('<front>');
294    $class = $this->xpath('//body[contains(@class, :class)]', [':class' => 'path-frontpage']);
295    $this->assertTrue(isset($class[0]), 'path-frontpage class found on the body tag');
296
297    // Check if the French frontpage has the right class.
298    $this->drupalGet('fr');
299    $class = $this->xpath('//body[contains(@class, :class)]', [':class' => 'path-frontpage']);
300    $this->assertTrue(isset($class[0]), 'path-frontpage class found on the body tag with french as the active language');
301
302  }
303
304  /**
305   * For authenticated users, the "active" class is set by JavaScript.
306   *
307   * @see self::testLanguageLinkActiveClass()
308   */
309  protected function doTestLanguageLinkActiveClassAuthenticated() {
310    $function_name = '#type link';
311    $path = 'language_test/type-link-active-class';
312
313    // Test links generated by the link generator on an English page.
314    $current_language = 'English';
315    $this->drupalGet($path);
316
317    // Language code 'none' link should be active.
318    $this->assertSession()->elementAttributeContains('named', ['id', 'no_lang_link'], 'data-drupal-link-system-path', $path);
319
320    // Language code 'en' link should be active.
321    $this->assertSession()->elementAttributeContains('named', ['id', 'en_link'], 'hreflang', 'en');
322    $this->assertSession()->elementAttributeContains('named', ['id', 'en_link'], 'data-drupal-link-system-path', $path);
323
324    // Language code 'fr' link should not be active.
325    $this->assertSession()->elementAttributeContains('named', ['id', 'fr_link'], 'hreflang', 'fr');
326    $this->assertSession()->elementAttributeContains('named', ['id', 'fr_link'], 'data-drupal-link-system-path', $path);
327
328    // Verify that drupalSettings contains the correct values.
329    $settings = $this->getDrupalSettings();
330    $this->assertSame($path, $settings['path']['currentPath'], 'drupalSettings.path.currentPath is set correctly to allow drupal.active-link to mark the correct links as active.');
331    $this->assertFalse($settings['path']['isFront'], 'drupalSettings.path.isFront is set correctly to allow drupal.active-link to mark the correct links as active.');
332    $this->assertSame('en', $settings['path']['currentLanguage'], 'drupalSettings.path.currentLanguage is set correctly to allow drupal.active-link to mark the correct links as active.');
333
334    // Test links generated by the link generator on a French page.
335    $current_language = 'French';
336    $this->drupalGet('fr/language_test/type-link-active-class');
337
338    // Language code 'none' link should be active.
339    $this->assertSession()->elementAttributeContains('named', ['id', 'no_lang_link'], 'data-drupal-link-system-path', $path);
340
341    // Language code 'en' link should not be active.
342    $this->assertSession()->elementAttributeContains('named', ['id', 'en_link'], 'hreflang', 'en');
343    $this->assertSession()->elementAttributeContains('named', ['id', 'en_link'], 'data-drupal-link-system-path', $path);
344
345    // Language code 'fr' link should be active.
346    $this->assertSession()->elementAttributeContains('named', ['id', 'fr_link'], 'hreflang', 'fr');
347    $this->assertSession()->elementAttributeContains('named', ['id', 'fr_link'], 'data-drupal-link-system-path', $path);
348
349    // Verify that drupalSettings contains the correct values.
350    $settings = $this->getDrupalSettings();
351    $this->assertSame($path, $settings['path']['currentPath'], 'drupalSettings.path.currentPath is set correctly to allow drupal.active-link to mark the correct links as active.');
352    $this->assertFalse($settings['path']['isFront'], 'drupalSettings.path.isFront is set correctly to allow drupal.active-link to mark the correct links as active.');
353    $this->assertSame('fr', $settings['path']['currentLanguage'], 'drupalSettings.path.currentLanguage is set correctly to allow drupal.active-link to mark the correct links as active.');
354  }
355
356  /**
357   * For anonymous users, the "active" class is set by PHP.
358   *
359   * @see self::testLanguageLinkActiveClass()
360   */
361  protected function doTestLanguageLinkActiveClassAnonymous() {
362    $function_name = '#type link';
363
364    $this->drupalLogout();
365
366    // Test links generated by the link generator on an English page.
367    $current_language = 'English';
368    $this->drupalGet('language_test/type-link-active-class');
369
370    // Language code 'none' link should be active.
371    $this->assertSession()->elementExists('xpath', "//a[@id = 'no_lang_link' and contains(@class, 'is-active')]");
372
373    // Language code 'en' link should be active.
374    $this->assertSession()->elementExists('xpath', "//a[@id = 'en_link' and contains(@class, 'is-active')]");
375
376    // Language code 'fr' link should not be active.
377    $this->assertSession()->elementExists('xpath', "//a[@id = 'fr_link' and not(contains(@class, 'is-active'))]");
378
379    // Test links generated by the link generator on a French page.
380    $current_language = 'French';
381    $this->drupalGet('fr/language_test/type-link-active-class');
382
383    // Language code 'none' link should be active.
384    $this->assertSession()->elementExists('xpath', "//a[@id = 'no_lang_link' and contains(@class, 'is-active')]");
385
386    // Language code 'en' link should not be active.
387    $this->assertSession()->elementExists('xpath', "//a[@id = 'en_link' and not(contains(@class, 'is-active'))]");
388
389    // Language code 'fr' link should be active.
390    $this->assertSession()->elementExists('xpath', "//a[@id = 'fr_link' and contains(@class, 'is-active')]");
391  }
392
393  /**
394   * Tests language switcher links for session based negotiation.
395   */
396  public function testLanguageSessionSwitchLinks() {
397    // Add language.
398    $edit = [
399      'predefined_langcode' => 'fr',
400    ];
401    $this->drupalGet('admin/config/regional/language/add');
402    $this->submitForm($edit, 'Add language');
403
404    // Enable session language detection and selection.
405    $edit = [
406      'language_interface[enabled][language-url]' => FALSE,
407      'language_interface[enabled][language-session]' => TRUE,
408    ];
409    $this->drupalGet('admin/config/regional/language/detection');
410    $this->submitForm($edit, 'Save settings');
411
412    // Enable the language switching block.
413    $this->drupalPlaceBlock('language_block:' . LanguageInterface::TYPE_INTERFACE, [
414      'id' => 'test_language_block',
415    ]);
416
417    // Enable the main menu block.
418    $this->drupalPlaceBlock('system_menu_block:main', [
419      'id' => 'test_menu',
420    ]);
421
422    // Add a link to the homepage.
423    $link = MenuLinkContent::create([
424      'title' => 'Home',
425      'menu_name' => 'main',
426      'bundle' => 'menu_link_content',
427      'link' => [['uri' => 'entity:user/2']],
428    ]);
429    $link->save();
430
431    // Go to the homepage.
432    $this->drupalGet('');
433    // Click on the French link.
434    $this->clickLink('French');
435    // There should be a query parameter to set the session language.
436    $this->assertSession()->addressEquals('user/2?language=fr');
437    // Click on the 'Home' Link.
438    $this->clickLink('Home');
439    // There should be no query parameter.
440    $this->assertSession()->addressEquals('user/2');
441    // Click on the French link.
442    $this->clickLink('French');
443    // There should be no query parameter.
444    $this->assertSession()->addressEquals('user/2');
445  }
446
447  /**
448   * Saves the native name of a language entity in configuration as a label.
449   *
450   * @param string $langcode
451   *   The language code of the language.
452   * @param string $label
453   *   The native name of the language.
454   */
455  protected function saveNativeLanguageName($langcode, $label) {
456    \Drupal::service('language.config_factory_override')
457      ->getOverride($langcode, 'language.entity.' . $langcode)->set('label', $label)->save();
458  }
459
460}
461