Verified Commit c1ace2bc authored by godotislate's avatar godotislate
Browse files

fix: #3607866 Scaffold plugin does not always create autoload_runtime.php when upgrading to 11.4

By: phily
By: xmacinfo
By: longwave
By: catch
By: kentr
By: godotislate
(cherry picked from commit 7f3e2517)
parent 10ee8884
Loading
Loading
Loading
Loading
Loading
+41 −0
Original line number Diff line number Diff line
@@ -103,6 +103,47 @@ public static function getSubscribedEvents() {
   */
  public function postCmd(Event $event) {
    $this->handler()->scaffold();
    $this->ensureAutoloadRuntimeFile();
  }

  /**
   * Generates autoload_runtime.php when Handler::scaffold() did not.
   *
   * When this plugin package is itself updated during a Composer command,
   * the Handler class may already have been loaded from the previous version
   * of the plugin (via postPackage()) before the plugin files were replaced
   * on disk. PHP keeps the loaded class for the rest of the process, so
   * Handler::scaffold() then runs the old code, which does not generate the
   * autoload_runtime.php file (introduced in Drupal 11.4.0).
   *
   * Composer reloads this Plugin class from the new plugin code after the
   * plugin package is updated. It must only use other classes of this plugin
   * through APIs that exist in every version of the plugin that can be updated
   * from, because those classes may still be loaded from a previous version.
   *
   * @todo Remove after upgrades from Drupal 11.3 are no longer supported.
   *
   * @see \Composer\Plugin\PluginManager::registerPackage()
   * @see https://www.drupal.org/project/drupal/issues/3607866
   */
  protected function ensureAutoloadRuntimeFile(): void {
    $manage_options = new ManageOptions($this->composer);
    $allowed_packages = (new AllowedPackages($this->composer, $this->io, $manage_options))->getAllowedPackages();
    if (empty($allowed_packages)) {
      return;
    }

    $web_root = $manage_options->getOptions()->getLocation('web-root');
    if (file_exists($web_root . '/autoload_runtime.php')) {
      return;
    }

    $vendor_dir = $this->composer->getConfig()->get('vendor-dir');
    $vendor_path = (new Filesystem())->normalizePath(realpath($vendor_dir));
    $package_name = $this->composer->getPackage()->getName();
    $result = GenerateAutoloadRuntimeReferenceFile::generateAutoloadRuntime($this->io, $package_name, $web_root, $vendor_path);
    $git_ignore_manager = new ManageGitIgnore($this->io, getcwd());
    $git_ignore_manager->manageIgnored([$result], $manage_options->getOptions());
  }

  /**
+118 −0
Original line number Diff line number Diff line
@@ -51,6 +51,13 @@ protected function setUp(): void {
    $this->fixtures->createIsolatedComposerCacheDir();
  }

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

  /**
   * Tests upgrading the Composer Scaffold plugin.
   */
@@ -99,6 +106,117 @@ public function testScaffoldUpgrade(): void {
    $this->assertFileExists("$sut/index.php");
  }

  /**
   * Tests upgrading the plugin when the Handler class is stale.
   *
   * @see \Drupal\Composer\Plugin\Scaffold\Plugin::ensureAutoloadRuntimeFile()
   */
  public function testUpgradeWithStaleHandler(): void {
    $this->fixturesDir = $this->fixtures->tmpDir($this->name());
    $pluginSource = $this->fixturesDir . '/composer-scaffold-plugin';
    $replacements = ['SYMLINK' => 'false', 'PROJECT_ROOT' => $pluginSource];
    $this->fixtures->cloneFixtureProjects($this->fixturesDir, $replacements);
    $sut = $this->fixturesDir . '/drupal-drupal';
    // The Scaffold plugin must be copied rather than symlinked into the
    // vendor directory, so that the installed plugin keeps the old code
    // after the plugin source is updated below.
    $composerJson = json_decode(file_get_contents("$sut/composer.json"), TRUE);
    $composerJson['repositories']['composer-scaffold']['options']['symlink'] = FALSE;
    file_put_contents("$sut/composer.json", json_encode($composerJson, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));

    // Create an "old" copy of the Scaffold plugin that behaves like the
    // plugin from before Drupal 11.4.0: it knows nothing about
    // autoload_runtime.php.
    $this->copyPluginVersion($pluginSource, '100.0.0', TRUE);
    $this->mustExec('composer install --no-ansi', $sut);
    $this->assertFileExists("$sut/autoload.php");
    $this->assertFileDoesNotExist("$sut/autoload_runtime.php");

    // Replace the plugin source with the current code, then require a
    // package containing a Composer plugin flagged with
    // 'plugin-modifies-downloads'. Composer will install that package before
    // the Scaffold plugin's update operation, so its post-package-install
    // event fires while the old Scaffold plugin code is still installed,
    // pinning the old Handler class for the remainder of the process.
    $this->copyPluginVersion($pluginSource, '100.0.1', FALSE);
    $composerJson['repositories']['downloads-modifier'] = [
      'type' => 'path',
      'url' => '../composer-plugin-downloads-modifier',
    ];
    $composerJson['require']['fixtures/composer-plugin-downloads-modifier'] = '*';
    $composerJson['config']['allow-plugins']['fixtures/composer-plugin-downloads-modifier'] = TRUE;
    file_put_contents("$sut/composer.json", json_encode($composerJson, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));

    $stdout = $this->mustExec('composer update --no-ansi --no-interaction 2>&1', $sut);

    // Verify that this test executed the stale Handler class: the
    // downloads-modifier plugin must have been installed before the Scaffold
    // plugin was updated, and the marker written by the stale Handler code
    // must appear in the output.
    $installPos = strpos($stdout, 'Installing fixtures/composer-plugin-downloads-modifier');
    $upgradePos = strpos($stdout, 'Upgrading drupal/core-composer-scaffold (100.0.0 => 100.0.1):');
    $this->assertNotFalse($installPos, $stdout);
    $this->assertNotFalse($upgradePos, $stdout);
    $this->assertLessThan($upgradePos, $installPos, $stdout);
    $this->assertStringContainsString('SCAFFOLD_TEST_STALE_HANDLER', $stdout);

    // Even though the stale Handler did not generate autoload_runtime.php,
    // the refreshed Plugin class must have generated it.
    $this->assertFileExists("$sut/autoload_runtime.php");
    $this->assertStringContainsString("require __DIR__ . '/vendor/autoload_runtime.php'", file_get_contents("$sut/autoload_runtime.php"));
  }

  /**
   * Copies the Scaffold plugin source to a target directory with a version.
   *
   * @param string $target
   *   Directory to place the plugin copy in.
   * @param string $version
   *   Version to set in the composer.json of the copy, so that the path
   *   repository provides an update when the version is bumped.
   * @param bool $stale
   *   When TRUE, remove all knowledge of autoload_runtime.php from the copy
   *   to simulate the plugin from before Drupal 11.4.0. A marker is written
   *   to output by the modified Handler so that tests can verify that the
   *   stale Handler code ran.
   */
  protected function copyPluginVersion(string $target, string $version, bool $stale): void {
    $filesystem = new Filesystem();
    $filesystem->remove($target);
    $filesystem->copy($this->fixtures->projectRoot(), $target);
    $composerJson = json_decode(file_get_contents("$target/composer.json"), TRUE);
    $composerJson['version'] = $version;
    file_put_contents("$target/composer.json", json_encode($composerJson, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
    if ($stale) {
      $generateRuntime = <<<'PHP'
    // The same is done for the autoload_runtime file that loads the Symfony
    // runtime.
    if (!GenerateAutoloadRuntimeReferenceFile::autoloadRuntimeFileCommitted($this->io, $this->rootPackageName(), $web_root)) {
      $scaffold_results[] = GenerateAutoloadRuntimeReferenceFile::generateAutoloadRuntime($this->io, $this->rootPackageName(), $web_root, $this->getVendorPath());
    }
PHP;
      $this->replaceCodeInFile("$target/Handler.php", $generateRuntime, "    \$this->io->write('SCAFFOLD_TEST_STALE_HANDLER');");
      $this->replaceCodeInFile("$target/Plugin.php", "    \$this->ensureAutoloadRuntimeFile();\n", '');
      unlink("$target/GenerateAutoloadRuntimeReferenceFile.php");
    }
  }

  /**
   * Replaces a code fragment in a file.
   *
   * @param string $path
   *   Path of the file to modify.
   * @param string $search
   *   The code fragment to replace.
   * @param string $replace
   *   The replacement code fragment.
   */
  protected function replaceCodeInFile(string $path, string $search, string $replace): void {
    $contents = file_get_contents($path);
    $this->assertStringContainsString($search, $contents, "Expected code not found in $path; update this test to match the current code.");
    file_put_contents($path, str_replace($search, $replace, $contents));
  }

  /**
   * Copy the provided source directory and create a temporary git repository.
   *
+16 −0
Original line number Diff line number Diff line
{
  "name": "fixtures/composer-plugin-downloads-modifier",
  "type": "composer-plugin",
  "require": {
    "composer-plugin-api": "^2"
  },
  "autoload": {
    "psr-4": {
      "Drupal\\Tests\\fixture\\Composer\\Plugin\\DownloadsModifier\\": "src"
    }
  },
  "extra": {
    "class": "Drupal\\Tests\\fixture\\Composer\\Plugin\\DownloadsModifier\\DownloadsModifierPlugin",
    "plugin-modifies-downloads": true
  }
}
+40 −0
Original line number Diff line number Diff line
<?php

declare(strict_types=1);

namespace Drupal\Tests\fixture\Composer\Plugin\DownloadsModifier;

use Composer\Composer;
use Composer\IO\IOInterface;
use Composer\Plugin\PluginInterface;

/**
 * A do-nothing Composer plugin flagged with 'plugin-modifies-downloads'.
 *
 * Composer moves the install operation of such a plugin in front of all
 * other plugin operations. Tests use this to force a post-package-install
 * event to fire before the Scaffold plugin's own update operation executes.
 *
 * @see \Composer\DependencyResolver\Transaction::movePluginsToFront()
 */
class DownloadsModifierPlugin implements PluginInterface {

  /**
   * {@inheritdoc}
   */
  public function activate(Composer $composer, IOInterface $io): void {
  }

  /**
   * {@inheritdoc}
   */
  public function deactivate(Composer $composer, IOInterface $io): void {
  }

  /**
   * {@inheritdoc}
   */
  public function uninstall(Composer $composer, IOInterface $io): void {
  }

}