Unverified Commit 1908b48d authored by Alex Pott's avatar Alex Pott
Browse files

fix: #3605554 Translations are never loaded or downloaded for a custom profile

By: phenaproxima
By: gábor hojtsy
(cherry picked from commit e439b183)
parent 4d79b69a
Loading
Loading
Loading
Loading
Loading
+20 −7
Original line number Diff line number Diff line
@@ -450,7 +450,7 @@ function install_begin_request($class_loader, &$install_state): void {
  }
  /** @var \Drupal\Core\File\FileSystemInterface $file_system */
  $file_system = $container->get('file_system');
  $container->set('string_translator.file_translation', new FileTranslation($directory, $file_system));
  $container->set('string_translator.file_translation', new FileTranslation($directory, $file_system, $install_state['parameters']['profile'] ?? NULL));
  $container->get('string_translation')
    ->addTranslator($container->get('string_translator.file_translation'));

@@ -1404,14 +1404,23 @@ function install_select_language(&$install_state) {
 */
function install_download_translation(&$install_state) {
  // Check whether all conditions are met to download. Download the translation
  // if possible.
  // for core if possible.
  $requirements = install_check_translations($install_state['parameters']['langcode'], $install_state['server_pattern']);
  // Render requirements if any warnings or errors returned.
  if ($output = install_display_requirements($install_state, $requirements)) {
    return $output;
  }

  // The download was successful, reload the page in the new language.
  $profile = $install_state['parameters']['profile'] ?? NULL;
  $profile_path = $install_state['profiles'][$profile]?->getPath();
  $profile_version = $install_state['profiles'][$profile]?->info['version'] ?? NULL;
  // If we're using a custom profile and we know its version, try to download
  // that translation too. If we can't, proceed without it.
  if (isset($profile, $profile_path, $profile_version) && !str_starts_with($profile_path, 'core/')) {
    install_check_translations($install_state['parameters']['langcode'], $install_state['server_pattern'], $profile, $profile_version);
  }

  // At least one download was successful, reload the page in the new language.
  $install_state['translations'][$install_state['parameters']['langcode']] = TRUE;
  if ($install_state['interactive']) {
    install_goto(install_redirect_url($install_state));
@@ -1846,12 +1855,18 @@ function _install_module_batch(array $modules, array $module_names, array &$cont
 *   Language code to check for download.
 * @param string $server_pattern
 *   Server access pattern (to replace language code, version number, etc. in).
 * @param string $project
 *   (optional) The name of the project for which to download translations.
 *   Defaults to `drupal` (i.e., core).
 * @param string $version
 *   (optional) The version of the project for which to download translations.
 *   Defaults to \Drupal::VERSION.
 *
 * @return array
 *   Requirements compliance array. If the translation cannot be downloaded this
 *   will contain a requirements error with detailed information.
 */
function install_check_translations($langcode, $server_pattern): array {
function install_check_translations($langcode, $server_pattern, string $project = 'drupal', string $version = \Drupal::VERSION): array {
  $requirements = [];

  $readable = FALSE;
@@ -1878,11 +1893,9 @@ function install_check_translations($langcode, $server_pattern): array {
    $translations_directory_exists = TRUE;
  }

  $version = \Drupal::VERSION;

  // Build URL for the translation file and the translation server.
  $variables = [
    '%project' => 'drupal',
    '%project' => $project,
    '%version' => $version,
    '%core' => 'all',
    '%language' => $langcode,
+11 −20
Original line number Diff line number Diff line
@@ -17,32 +17,22 @@
 */
class FileTranslation extends StaticTranslation {

  /**
   * Directory to find translation files in the file system.
   *
   * @var string
   */
  protected $directory;

  /**
   * The file system.
   *
   * @var \Drupal\Core\File\FileSystemInterface
   */
  protected $fileSystem;

  /**
   * Constructs a StaticTranslation object.
   *
   * @param string $directory
   *   The directory to retrieve file translations from.
   * @param \Drupal\Core\File\FileSystemInterface $file_system
   * @param \Drupal\Core\File\FileSystemInterface $fileSystem
   *   The file system service.
   * @param string|null $profile
   *   The machine name of the install profile, or NULL if not known.
   */
  public function __construct($directory, FileSystemInterface $file_system) {
  public function __construct(
    protected string $directory,
    protected FileSystemInterface $fileSystem,
    protected ?string $profile = NULL,
  ) {
    parent::__construct();
    $this->directory = $directory;
    $this->fileSystem = $file_system;
  }

  /**
@@ -100,10 +90,11 @@ public function findTranslationFiles($langcode = NULL) {
   *   String file pattern.
   */
  protected function getTranslationFilesPattern($langcode = NULL) {
    // The file name matches: drupal-[release version].[language code].po
    $project = $this->profile ? "(drupal|$this->profile)" : 'drupal';
    // Matches (drupal|[profile])-[any-version].[language code].po
    // When provided the $langcode is use as language code. If not provided all
    // language codes will match.
    return '!drupal-[0-9]+\.[0-9]+(\.([0-9]+|x))?(-[a-z]+[0-9]*)?\.' . (!empty($langcode) ? preg_quote($langcode, '!') : LanguageInterface::VALID_LANGCODE_REGEX) . '\.po$!';
    return '!' . $project . '-.+\.' . (!empty($langcode) ? preg_quote($langcode, '!') : LanguageInterface::VALID_LANGCODE_REGEX) . '\.po$!';
  }

  /**
+191 −0
Original line number Diff line number Diff line
<?php

declare(strict_types=1);

namespace Drupal\KernelTests\Core\Installer;

use Drupal\Core\DependencyInjection\ContainerBuilder;
use Drupal\Core\Extension\Extension;
use Drupal\Core\Extension\ExtensionDiscovery;
use Drupal\Core\File\FileSystemInterface;
use Drupal\Core\StringTranslation\Translator\FileTranslation;
use Drupal\KernelTests\KernelTestBase;
use GuzzleHttp\Client;
use GuzzleHttp\Psr7\Response;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses;
use PHPUnit\Framework\Attributes\TestWith;
use Psr\Http\Message\RequestInterface;

/**
 * Tests that profile translations are downloaded by the installer.
 */
#[Group('Installer')]
#[RunTestsInSeparateProcesses]
class InstallerTranslationDownloadTest extends KernelTestBase {

  /**
   * The original error handler, which will be overwritten by the installer.
   *
   * @var callable|null
   */
  private $errorHandler;

  /**
   * An array of files that were requested, and by which methods.
   */
  private array $requests = [];

  /**
   * The parameters to pass to install_drupal().
   */
  private array $installParameters = [
    'parameters' => [
      'langcode' => 'de',
    ],
    'forms' => [
      'install_configure_form' => [
        'site_name' => 'Test',
        'site_mail' => 'admin@example.com',
        'account' => [
          'name' => 'admin',
          'mail' => 'admin@example.com',
          'pass' => [
            'pass1' => 'admin',
            'pass2' => 'admin',
          ],
        ],
      ],
    ],
    'interactive' => FALSE,
  ];

  /**
   * {@inheritdoc}
   */
  protected function setUp(): void {
    parent::setUp();

    // We will be calling installer functions directly.
    require_once 'core/includes/install.core.inc';

    // We need to restore the error handler before tearDown, or the testing
    // system will yell at us.
    $this->errorHandler = get_error_handler();
  }

  /**
   * {@inheritdoc}
   */
  protected function tearDown(): void {
    set_error_handler($this->errorHandler);
    parent::tearDown();
  }

  /**
   * {@inheritdoc}
   */
  public function register(ContainerBuilder $container): void {
    $handler = function (RequestInterface $request): Response {
      $this->requests[] = $request->getMethod() . ' ' . basename($request->getUri()->getPath());
      // We don't care about the response; we're just ensuring that certain
      // requests are made.
      return new Response(body: "msgid: \"\"\nmsgstr \"\"");
    };
    $client = new Client(['handler' => $handler]);
    $container->set('http_client', $client);

    parent::register($container);
  }

  /**
   * Tests that the translations are downloaded for a custom profile.
   *
   * @param string $version
   *   The version of the custom profile.
   */
  #[TestWith(['1.2.3'], 'Semantic version')]
  #[TestWith(['2.x-dev'], 'Dev branch with -dev suffix')]
  #[TestWith(['2.x'], 'Dev branch without -dev suffix')]
  #[TestWith(['8.x-1.3'], 'Legacy tagged version')]
  #[TestWith(['2.1.0-alpha2'], 'Pre-release semantic version')]
  #[TestWith(['8.x-1.x-dev'], 'Legacy dev branch with -dev suffix')]
  #[TestWith(['8.x-1.x'], 'Legacy dev branch without -dev suffix')]
  public function testCustomProfileTranslationsAreDownloaded(string $version): void {
    // In kernel tests, the site directory is a VFS URI. We need to make it real
    // so the Extension class can access it.
    $pathname = parse_url($this->siteDirectory, PHP_URL_PATH);
    $pathname = trim($pathname, '/');
    $pathname .= '/profiles/foo';
    mkdir($pathname, recursive: TRUE);

    // Make a fake, non-core profile and explicitly expose it to the profile
    // discovery.
    $pathname .= '/foo.info.yml';
    $info = <<<EOF
name: Test
type: profile
version: $version
core_version_requirement: '*'
EOF;
    file_put_contents($pathname, $info);

    // We just created a new profile, but extensions have already been scanned.
    // Therefore, clear the extension discovery cache so the installer can
    // find the new profile.
    (new \ReflectionProperty(ExtensionDiscovery::class, 'files'))
      ->setValue(NULL, []);

    // Expose our fake profile directly to the installer.
    $this->installParameters['profiles']['foo'] = new Extension($this->root, 'profile', $pathname);
    $this->installParameters['parameters']['profile'] = 'foo';
    install_drupal($this->classLoader, $this->installParameters);

    // Four requests should have been made: a HEAD and a GET request for each
    // of two files.
    $core_version = \Drupal::VERSION;
    $expected_requests = [
      "HEAD drupal-$core_version.de.po",
      "GET drupal-$core_version.de.po",
      "HEAD foo-$version.de.po",
      "GET foo-$version.de.po",
    ];
    $this->assertSame($expected_requests, $this->requests);

    // Confirm that the files were actually downloaded.
    $destination = dirname($pathname, 3) . '/files/translations';
    $this->assertFileExists($destination . "/drupal-$core_version.de.po");
    $this->assertFileExists($destination . "/foo-$version.de.po");
    // Put another unrelated translation file into the directory to ensure that
    // FileTranslation doesn't see it.
    touch($destination . "/unrelated-$version.de.po");

    // Confirm that the FileTranslation service used during the early installer
    // finds the downloaded files.
    $translation = new FileTranslation(
      $destination,
      \Drupal::service(FileSystemInterface::class),
      'foo',
    );
    $found_translations = array_column($translation->findTranslationFiles(), 'filename');
    sort($found_translations);
    $expected_translations = [
      "drupal-$core_version.de.po",
      "foo-$version.de.po",
    ];
    $this->assertSame($expected_translations, $found_translations);
  }

  /**
   * Tests that translations are not downloaded for core profiles.
   */
  public function testCoreProfileTranslationsAreNotDownloaded(): void {
    $this->installParameters['parameters']['profile'] = 'testing';
    install_drupal($this->classLoader, $this->installParameters);

    $this->assertCount(2, $this->requests);
    $this->assertStringStartsWith("HEAD drupal-", $this->requests[0]);
    $this->assertStringStartsWith("GET drupal-", $this->requests[1]);
  }

}