From 7e8c3d2818bcf3827308e532420d529bb720a253 Mon Sep 17 00:00:00 2001
From: Ted Bowman <ted@tedbow.com>
Date: Thu, 6 Jan 2022 13:47:44 -0700
Subject: [PATCH] Contrib: Issue #3257115 by phenaproxima: Convert
 ExcludedPathsTest into a kernel test -
 https://git.drupalcode.org/project/automatic_updates/-/commit/46cab4c87b2c8c1816d109baa2c7a49caa59e0b1

---
 .../auto_updates/auto_updates.services.yml    |   6 -
 .../src/Event/ExcludedPathsSubscriber.php     |  56 ----
 .../tests/src/Build/CoreUpdateTest.php        | 290 +++++++----------
 .../src/Build/TemplateProjectSiteTestBase.php | 295 ++++++++++++++++++
 .../tests/src/Build/UpdateTestBase.php        | 240 +++-----------
 .../tests/src/Functional/UpdaterFormTest.php  |   5 +-
 .../tests/src/Kernel/CronUpdaterTest.php      |   5 +-
 .../StagedProjectsValidatorTest.php           |   1 +
 .../tests/src/Kernel/UpdaterTest.php          |  20 +-
 .../tests/src/Traits/JsonTrait.php            |  41 ---
 .../tests/src/Traits/LocalPackagesTrait.php   | 163 ----------
 .../tests/src/Traits/SettingsTrait.php        |  57 ----
 .../package_manager.services.yml              |  10 +-
 .../src/Event/ExcludedPathsTrait.php          |  17 +-
 .../ComposerExecutableValidator.php           |  10 +-
 .../ExcludedPathsSubscriber.php               | 169 ++++++----
 .../package_manager/src/FileSyncerFactory.php |  14 +-
 core/modules/package_manager/src/Stage.php    |   7 +-
 .../tests/fixtures/fake_site/vendor/.htaccess |   1 +
 .../package_manager_bypass/src/Beginner.php   |   4 +-
 .../package_manager_bypass/src/Committer.php  |   4 +-
 .../package_manager_bypass/src/Stager.php     |   4 +-
 .../src/Functional/ExcludedPathsTest.php      | 155 ---------
 .../ComposerExecutableValidatorTest.php       |   4 +-
 .../Kernel/ComposerSettingsValidatorTest.php  |   1 +
 .../src/Kernel/DiskSpaceValidatorTest.php     |   1 +
 .../Kernel/ExcludedPathsSubscriberTest.php    |  10 +-
 .../tests/src/Kernel/ExcludedPathsTest.php    | 227 ++++++++++++++
 .../Kernel/PackageManagerKernelTestBase.php   |  22 ++
 .../WritableFileSystemValidatorTest.php       |   1 +
 30 files changed, 888 insertions(+), 952 deletions(-)
 delete mode 100644 core/modules/auto_updates/src/Event/ExcludedPathsSubscriber.php
 create mode 100644 core/modules/auto_updates/tests/src/Build/TemplateProjectSiteTestBase.php
 delete mode 100644 core/modules/auto_updates/tests/src/Traits/JsonTrait.php
 delete mode 100644 core/modules/auto_updates/tests/src/Traits/LocalPackagesTrait.php
 delete mode 100644 core/modules/auto_updates/tests/src/Traits/SettingsTrait.php
 create mode 100644 core/modules/package_manager/tests/fixtures/fake_site/vendor/.htaccess
 delete mode 100644 core/modules/package_manager/tests/src/Functional/ExcludedPathsTest.php
 create mode 100644 core/modules/package_manager/tests/src/Kernel/ExcludedPathsTest.php

diff --git a/core/modules/auto_updates/auto_updates.services.yml b/core/modules/auto_updates/auto_updates.services.yml
index b8715b3450e5..29b2fdbc1b6c 100644
--- a/core/modules/auto_updates/auto_updates.services.yml
+++ b/core/modules/auto_updates/auto_updates.services.yml
@@ -30,12 +30,6 @@ services:
       - '@file_system'
       - '@event_dispatcher'
       - '@tempstore.shared'
-  auto_updates.excluded_paths_subscriber:
-    class: Drupal\auto_updates\Event\ExcludedPathsSubscriber
-    arguments:
-      - '@extension.list.module'
-    tags:
-      - { name: event_subscriber }
   auto_updates.staged_projects_validator:
     class: Drupal\auto_updates\Validator\StagedProjectsValidator
     arguments:
diff --git a/core/modules/auto_updates/src/Event/ExcludedPathsSubscriber.php b/core/modules/auto_updates/src/Event/ExcludedPathsSubscriber.php
deleted file mode 100644
index a961b2b2ed6b..000000000000
--- a/core/modules/auto_updates/src/Event/ExcludedPathsSubscriber.php
+++ /dev/null
@@ -1,56 +0,0 @@
-<?php
-
-namespace Drupal\auto_updates\Event;
-
-use Drupal\auto_updates\Updater;
-use Drupal\Core\Extension\ModuleExtensionList;
-use Drupal\package_manager\Event\PreCreateEvent;
-use Symfony\Component\EventDispatcher\EventSubscriberInterface;
-
-/**
- * Defines an event subscriber to exclude certain paths from update operations.
- */
-class ExcludedPathsSubscriber implements EventSubscriberInterface {
-
-  /**
-   * The module list service.
-   *
-   * @var \Drupal\Core\Extension\ModuleExtensionList
-   */
-  protected $moduleList;
-
-  /**
-   * Constructs an UpdateSubscriber.
-   *
-   * @param \Drupal\Core\Extension\ModuleExtensionList $module_list
-   *   The module list service.
-   */
-  public function __construct(ModuleExtensionList $module_list) {
-    $this->moduleList = $module_list;
-  }
-
-  /**
-   * Reacts to the beginning of an update process.
-   *
-   * @param \Drupal\package_manager\Event\PreCreateEvent $event
-   *   The event object.
-   */
-  public function preCreate(PreCreateEvent $event): void {
-    // If we are doing an automatic update and this module is a git clone,
-    // exclude it.
-    if ($event->getStage() instanceof Updater && is_dir(__DIR__ . '/../../.git')) {
-      $dir = $this->moduleList->getPath('auto_updates');
-      $event->excludePath($dir);
-    }
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public static function getSubscribedEvents() {
-    return [
-      PreCreateEvent::class => 'preCreate',
-    ];
-  }
-
-}
diff --git a/core/modules/auto_updates/tests/src/Build/CoreUpdateTest.php b/core/modules/auto_updates/tests/src/Build/CoreUpdateTest.php
index a8e670912e41..be3070d61b21 100644
--- a/core/modules/auto_updates/tests/src/Build/CoreUpdateTest.php
+++ b/core/modules/auto_updates/tests/src/Build/CoreUpdateTest.php
@@ -2,7 +2,7 @@
 
 namespace Drupal\Tests\auto_updates\Build;
 
-use Drupal\Component\Serialization\Json;
+use Drupal\Composer\Composer;
 
 /**
  * Tests an end-to-end update of Drupal core.
@@ -14,153 +14,43 @@ class CoreUpdateTest extends UpdateTestBase {
   /**
    * {@inheritdoc}
    */
-  protected function createTestSite(string $template): void {
-    $dir = $this->getWorkspaceDirectory();
-
-    // Build the test site and alter its copy of core so that it thinks it's
-    // running Drupal 9.8.0, which will never actually exist in the real world.
-    // Then, prepare a secondary copy of the core code base, masquerading as
-    // Drupal 9.8.1, which will be the version of core we update to. These two
-    // versions are referenced in the fake release metadata in our fake release
-    // metadata (see fixtures/release-history/drupal.0.0.xml).
-    parent::createTestSite($template);
-    $this->setCoreVersion($this->getWebRoot() . '/core', '9.8.0');
-    $this->alterPackage($dir, $this->getConfigurationForUpdate('9.8.1'));
-
-    // Install Drupal and ensure it's using the fake release metadata to fetch
-    // information about available updates.
-    $this->installQuickStart('minimal');
-    $this->setReleaseMetadata(['drupal' => '9.8.1-security']);
-    $this->formLogin($this->adminUsername, $this->adminPassword);
-    $this->installModules([
-      'auto_updates',
-      'auto_updates_test',
-      'update_test',
-    ]);
-
-    // If using the drupal/recommended-project template, we don't expect there
-    // to be an .htaccess file at the project root. One would normally be
-    // generated by Composer when Package Manager or other code creates a
-    // ComposerUtility object in the active directory, except that Package
-    // Manager takes specific steps to prevent that. So, here we're just
-    // confirming that, in fact, Composer's .htaccess protection was disabled.
-    // We don't do this for the drupal/legacy-project template because its
-    // project root, which is also the document root, SHOULD contain a .htaccess
-    // generated by Drupal core.
-    // We do this check because this test uses PHP's built-in web server, which
-    // ignores .htaccess files and everything in them, so a Composer-generated
-    // .htaccess file won't cause this test to fail.
-    if ($template === 'drupal/recommended-project') {
-      $this->assertFileDoesNotExist($dir . '/.htaccess');
-    }
+  public function copyCodebase(\Iterator $iterator = NULL, $working_dir = NULL) {
+    parent::copyCodebase($iterator, $working_dir);
 
-    // Ensure that Drupal thinks we are running 9.8.0, then refresh information
-    // about available updates.
-    $this->assertCoreVersion('9.8.0');
-    $this->checkForUpdates();
-    // Ensure that an update to 9.8.1 is available.
-    $this->visit('/admin/modules/automatic-update');
-    $this->getMink()->assertSession()->pageTextContains('9.8.1');
+    // Ensure that we will install Drupal 9.8.0 (a fake version that should
+    // never exist in real life) initially.
+    $this->setUpstreamCoreVersion('9.8.0');
   }
 
   /**
    * {@inheritdoc}
    */
-  protected function tearDown(): void {
-    if ($this->destroyBuild) {
-      $this->deleteCopiedPackages();
-    }
-    parent::tearDown();
-  }
-
-  /**
-   * Modifies a Drupal core code base to set its version.
-   *
-   * @param string $dir
-   *   The directory of the Drupal core code base.
-   * @param string $version
-   *   The version number to set.
-   */
-  private function setCoreVersion(string $dir, string $version): void {
-    $this->alterPackage($dir, ['version' => $version]);
-
-    $drupal_php = "$dir/lib/Drupal.php";
-    $this->assertIsWritable($drupal_php);
-    $code = file_get_contents($drupal_php);
-    $code = preg_replace("/const VERSION = '([0-9]+\.?){3}(-dev)?';/", "const VERSION = '$version';", $code);
-    file_put_contents($drupal_php, $code);
+  public function getCodebaseFinder() {
+    // Don't copy .git directories and such, since that just slows things down.
+    // We can use ::setUpstreamCoreVersion() to explicitly set the versions of
+    // core packages required by the test site.
+    return parent::getCodebaseFinder()->ignoreVCS(TRUE);
   }
 
   /**
-   * Returns composer.json changes that are needed to update core.
-   *
-   * This will clone the following packages into temporary directories:
-   * - drupal/core
-   * - drupal/core-recommended
-   * - drupal/core-project-message
-   * - drupal/core-composer-scaffold
-   * The cloned packages will be assigned the given version number, and the test
-   * site's composer.json will use the clones as path repositories.
-   *
-   * @param string $version
-   *   The version of core we will be updating to.
-   *
-   * @return array
-   *   The changes to merge into the test site's composer.json.
+   * {@inheritdoc}
    */
-  protected function getConfigurationForUpdate(string $version): array {
-    $repositories = [];
-
-    // Create a fake version of core with the given version number, and change
-    // its README so that we can actually be certain that we update to this
-    // fake version.
-    $dir = $this->copyPackage($this->getWebRoot() . '/core');
-    $this->setCoreVersion($dir, $version);
-    file_put_contents("$dir/README.txt", "Placeholder for Drupal core $version.");
-    $repositories['drupal/core'] = $this->createPathRepository($dir);
-
-    $drupal_root = $this->getDrupalRoot();
-
-    // Create a fake version of drupal/core-recommended which itself requires
-    // the fake version of core we just created.
-    $dir = $this->copyPackage("$drupal_root/composer/Metapackage/CoreRecommended");
-    $this->alterPackage($dir, [
-      'require' => [
-        'drupal/core' => $version,
-      ],
-      'version' => $version,
-    ]);
-    $repositories['drupal/core-recommended'] = $this->createPathRepository($dir);
-
-    // Create fake target versions of core plugins and metapackages.
-    $packages = [
-      'drupal/core-dev' => "$drupal_root/composer/Metapackage/DevDependencies",
-      'drupal/core-project-message' => "$drupal_root/composer/Plugin/ProjectMessage",
-      'drupal/core-composer-scaffold' => "$drupal_root/composer/Plugin/Scaffold",
-      'drupal/core-vendor-hardening' => "$drupal_root/composer/Plugin/VendorHardening",
-    ];
-    foreach ($packages as $name => $dir) {
-      $dir = $this->copyPackage($dir);
-      $this->alterPackage($dir, ['version' => $version]);
-      $repositories[$name] = $this->createPathRepository($dir);
-    }
-
-    return [
-      'repositories' => $repositories,
-    ];
-  }
+  protected function createTestProject(string $template): void {
+    parent::createTestProject($template);
+
+    // Prepare an "upstream" version of core, 9.8.1, to which we will update.
+    // This version, along with 9.8.0 (which was installed initially), is
+    // referenced in our fake release metadata (see
+    // fixtures/release-history/drupal.0.0.xml).
+    $this->setUpstreamCoreVersion('9.8.1');
+    $this->setReleaseMetadata(['drupal' => '9.8.1-security']);
 
-  /**
-   * Data provider for end-to-end update tests.
-   *
-   * @return array[]
-   *   Sets of arguments to pass to the test method.
-   */
-  public function providerTemplate(): array {
-    return [
-      ['drupal/recommended-project'],
-      ['drupal/legacy-project'],
-    ];
+    // Ensure that Drupal thinks we are running 9.8.0, then refresh information
+    // about available updates and ensure that an update to 9.8.1 is available.
+    $this->assertCoreVersion('9.8.0');
+    $this->checkForUpdates();
+    $this->visit('/admin/modules/automatic-update');
+    $this->getMink()->assertSession()->pageTextContains('9.8.1');
   }
 
   /**
@@ -172,14 +62,14 @@ public function providerTemplate(): array {
    * @dataProvider providerTemplate
    */
   public function testApi(string $template): void {
-    $this->createTestSite($template);
+    $this->createTestProject($template);
 
     $mink = $this->getMink();
     $assert_session = $mink->assertSession();
 
     // Ensure that the update is prevented if the web root and/or vendor
     // directories are not writable.
-    $this->assertReadOnlyFileSystemError($template, '/automatic-update-test/update/9.8.1');
+    $this->assertReadOnlyFileSystemError('/automatic-update-test/update/9.8.1');
 
     $mink->getSession()->reload();
     $assert_session->pageTextContains('9.8.1');
@@ -194,7 +84,7 @@ public function testApi(string $template): void {
    * @dataProvider providerTemplate
    */
   public function testUi(string $template): void {
-    $this->createTestSite($template);
+    $this->createTestProject($template);
 
     $mink = $this->getMink();
     $session = $mink->getSession();
@@ -207,7 +97,7 @@ public function testUi(string $template): void {
 
     // Ensure that the update is prevented if the web root and/or vendor
     // directories are not writable.
-    $this->assertReadOnlyFileSystemError($template, parse_url($session->getCurrentUrl(), PHP_URL_PATH));
+    $this->assertReadOnlyFileSystemError(parse_url($session->getCurrentUrl(), PHP_URL_PATH));
     $session->reload();
 
     $assert_session->pageTextNotContains('There is a security update available for your version of Drupal.');
@@ -218,7 +108,7 @@ public function testUi(string $template): void {
     $this->waitForBatchJob();
     $assert_session->pageTextContains('Update complete!');
     $assert_session->pageTextNotContains('There is a security update available for your version of Drupal.');
-    $this->assertUpdateSuccessful();
+    $this->assertUpdateSuccessful('9.8.1');
   }
 
   /**
@@ -230,43 +120,35 @@ public function testUi(string $template): void {
    * @dataProvider providerTemplate
    */
   public function testCron(string $template): void {
-    $this->createTestSite($template);
+    $this->createTestProject($template);
 
     $this->visit('/admin/reports/status');
     $this->getMink()->getSession()->getPage()->clickLink('Run cron');
-    $this->assertUpdateSuccessful();
+    $this->assertUpdateSuccessful('9.8.1');
   }
 
   /**
    * Asserts that the update is prevented if the filesystem isn't writable.
    *
-   * @param string $template
-   *   The project template used to build the test site. See ::createTestSite()
-   *   for the possible values.
-   * @param string $url
+   * @param string $error_url
    *   A URL where we can see the error message which is raised when parts of
    *   the file system are not writable. This URL will be visited twice: once
    *   for the web root, and once for the vendor directory.
    */
-  private function assertReadOnlyFileSystemError(string $template, string $url): void {
+  private function assertReadOnlyFileSystemError(string $error_url): void {
     $directories = [
       'Drupal' => rtrim($this->getWebRoot(), './'),
     ];
 
     // The location of the vendor directory depends on which project template
-    // was used to build the test site.
-    if ($template === 'drupal/recommended-project') {
-      $directories['vendor'] = $this->getWorkspaceDirectory() . '/vendor';
-    }
-    elseif ($template === 'drupal/legacy-project') {
-      $directories['vendor'] = $directories['Drupal'] . '/vendor';
-    }
+    // was used to build the test site, so just ask Composer where it is.
+    $directories['vendor'] = $this->runComposer('composer config --absolute vendor-dir', 'project');
 
     $assert_session = $this->getMink()->assertSession();
     foreach ($directories as $type => $path) {
       chmod($path, 0555);
       $this->assertDirectoryIsNotWritable($path);
-      $this->visit($url);
+      $this->visit($error_url);
       $assert_session->pageTextContains("The $type directory \"$path\" is not writable.");
       chmod($path, 0755);
       $this->assertDirectoryIsWritable($path);
@@ -274,43 +156,97 @@ private function assertReadOnlyFileSystemError(string $template, string $url): v
   }
 
   /**
-   * Asserts that Drupal core was successfully updated.
+   * Sets the version of Drupal core to which the test site will be updated.
+   *
+   * @param string $version
+   *   The Drupal core version to set.
+   */
+  private function setUpstreamCoreVersion(string $version): void {
+    $workspace_dir = $this->getWorkspaceDirectory();
+
+    // Loop through core's metapackages and plugins, and alter them as needed.
+    $packages = str_replace("$workspace_dir/", NULL, $this->getCorePackages());
+    foreach ($packages as $path) {
+      // Assign the new upstream version.
+      $this->runComposer("composer config version $version", $path);
+
+      // If this package requires Drupal core (e.g., drupal/core-recommended),
+      // make it require the new upstream version.
+      $info = $this->runComposer('composer info --self --format json', $path, TRUE);
+      if (isset($info['requires']['drupal/core'])) {
+        $this->runComposer("composer require --no-update drupal/core:$version", $path);
+      }
+    }
+
+    // Change the \Drupal::VERSION constant and put placeholder text in the
+    // README so we can ensure that we really updated to the correct version.
+    // @see ::assertUpdateSuccessful()
+    Composer::setDrupalVersion($workspace_dir, $version);
+    file_put_contents("$workspace_dir/core/README.txt", "Placeholder for Drupal core $version.");
+  }
+
+  /**
+   * Asserts that a specific version of Drupal core is running.
+   *
+   * Assumes that a user with permission to view the status report is logged in.
+   *
+   * @param string $expected_version
+   *   The version of core that should be running.
+   */
+  protected function assertCoreVersion(string $expected_version): void {
+    $this->visit('/admin/reports/status');
+    $item = $this->getMink()
+      ->assertSession()
+      ->elementExists('css', 'h3:contains("Drupal Version")')
+      ->getParent()
+      ->getText();
+    $this->assertStringContainsString($expected_version, $item);
+  }
+
+  /**
+   * Asserts that Drupal core was updated successfully.
+   *
+   * Assumes that a user with appropriate permissions is logged in.
+   *
+   * @param string $expected_version
+   *   The expected active version of Drupal core.
    */
-  private function assertUpdateSuccessful(): void {
+  private function assertUpdateSuccessful(string $expected_version): void {
     // The update form should not have any available updates.
     // @todo Figure out why this assertion fails when the batch processor
     //   redirects directly to the update form, instead of update.status, when
     //   updating via the UI.
     $this->visit('/admin/modules/automatic-update');
     $this->getMink()->assertSession()->pageTextContains('No update available');
-    // The status page should report that we're running Drupal 9.8.1.
-    $this->assertCoreVersion('9.8.1');
-    // The fake placeholder text from ::getConfigurationForUpdate() should be
-    // present in the README.
+
+    // The status page should report that we're running the expected version and
+    // the README should contain the placeholder text written by
+    // ::setUpstreamCoreVersion().
+    $this->assertCoreVersion($expected_version);
     $placeholder = file_get_contents($this->getWebRoot() . '/core/README.txt');
-    $this->assertSame('Placeholder for Drupal core 9.8.1.', $placeholder);
+    $this->assertSame("Placeholder for Drupal core $expected_version.", $placeholder);
+
+    $info = $this->runComposer('composer info --self --format json', 'project', TRUE);
 
-    $composer = file_get_contents($this->getWorkspaceDirectory() . '/composer.json');
-    $composer = Json::decode($composer);
     // The production dependencies should have been updated.
-    $this->assertSame('9.8.1', $composer['require']['drupal/core-recommended']);
-    $this->assertSame('9.8.1', $composer['require']['drupal/core-composer-scaffold']);
-    $this->assertSame('9.8.1', $composer['require']['drupal/core-project-message']);
+    $this->assertSame($expected_version, $info['requires']['drupal/core-recommended']);
+    $this->assertSame($expected_version, $info['requires']['drupal/core-composer-scaffold']);
+    $this->assertSame($expected_version, $info['requires']['drupal/core-project-message']);
     // The core-vendor-hardening plugin is only used by the legacy project
     // template.
-    if ($composer['name'] === 'drupal/legacy-project') {
-      $this->assertSame('9.8.1', $composer['require']['drupal/core-vendor-hardening']);
+    if ($info['name'] === 'drupal/legacy-project') {
+      $this->assertSame($expected_version, $info['requires']['drupal/core-vendor-hardening']);
     }
     // The production dependencies should not be listed as dev dependencies.
-    $this->assertArrayNotHasKey('drupal/core-recommended', $composer['require-dev']);
-    $this->assertArrayNotHasKey('drupal/core-composer-scaffold', $composer['require-dev']);
-    $this->assertArrayNotHasKey('drupal/core-project-message', $composer['require-dev']);
-    $this->assertArrayNotHasKey('drupal/core-vendor-hardening', $composer['require-dev']);
+    $this->assertArrayNotHasKey('drupal/core-recommended', $info['devRequires']);
+    $this->assertArrayNotHasKey('drupal/core-composer-scaffold', $info['devRequires']);
+    $this->assertArrayNotHasKey('drupal/core-project-message', $info['devRequires']);
+    $this->assertArrayNotHasKey('drupal/core-vendor-hardening', $info['devRequires']);
 
     // The drupal/core-dev metapackage should not be a production dependency...
-    $this->assertArrayNotHasKey('drupal/core-dev', $composer['require']);
+    $this->assertArrayNotHasKey('drupal/core-dev', $info['requires']);
     // ...but it should have been updated in the dev dependencies.
-    $this->assertSame('9.8.1', $composer['require-dev']['drupal/core-dev']);
+    $this->assertSame($expected_version, $info['devRequires']['drupal/core-dev']);
   }
 
 }
diff --git a/core/modules/auto_updates/tests/src/Build/TemplateProjectSiteTestBase.php b/core/modules/auto_updates/tests/src/Build/TemplateProjectSiteTestBase.php
new file mode 100644
index 000000000000..dbf0d3d8301c
--- /dev/null
+++ b/core/modules/auto_updates/tests/src/Build/TemplateProjectSiteTestBase.php
@@ -0,0 +1,295 @@
+<?php
+
+namespace Drupal\Tests\auto_updates\Build;
+
+use Drupal\BuildTests\QuickStart\QuickStartTestBase;
+use Drupal\Composer\Composer;
+
+/**
+ * Base class for tests which create a test site from a core project template.
+ */
+abstract class TemplateProjectSiteTestBase extends QuickStartTestBase {
+
+  /**
+   * The web root of the test site, relative to the workspace directory.
+   *
+   * @var string
+   */
+  private $webRoot;
+
+  /**
+   * Data provider for tests which use all of the core project templates.
+   *
+   * @return array[]
+   *   Sets of arguments to pass to the test method.
+   */
+  public function providerTemplate(): array {
+    return [
+      ['RecommendedProject'],
+      ['LegacyProject'],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function copyCodebase(\Iterator $iterator = NULL, $working_dir = NULL) {
+    parent::copyCodebase($iterator, $working_dir);
+
+    // In certain situations, like Drupal CI, auto_updates might be
+    // required into the code base by Composer. This may cause it to be added to
+    // the drupal/core-recommended metapackage, which can prevent the test site
+    // from being built correctly, among other deleterious effects. To prevent
+    // such shenanigans, always remove drupal/auto_updates from
+    // drupal/core-recommended.
+    $this->runComposer('composer remove --no-update drupal/auto_updates', 'composer/Metapackage/CoreRecommended');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getCodebaseFinder() {
+    // If core's npm dependencies are installed, we don't want them to be
+    // included in the upstream version of core that gets installed into the
+    // test site.
+    return parent::getCodebaseFinder()->notPath('#^core/node_modules#');
+  }
+
+  /**
+   * Returns the full path to the test site's document root.
+   *
+   * @return string
+   *   The full path of the test site's document root.
+   */
+  protected function getWebRoot(): string {
+    return $this->getWorkspaceDirectory() . '/' . $this->webRoot;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function instantiateServer($port, $working_dir = NULL) {
+    return parent::instantiateServer($port, $working_dir ?: $this->webRoot);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function installQuickStart($profile, $working_dir = NULL) {
+    parent::installQuickStart($profile, $working_dir ?: $this->webRoot);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function visit($request_uri = '/', $working_dir = NULL) {
+    return parent::visit($request_uri, $working_dir ?: $this->webRoot);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function formLogin($username, $password, $working_dir = NULL) {
+    parent::formLogin($username, $password, $working_dir ?: $this->webRoot);
+  }
+
+  /**
+   * Returns the paths of all core Composer packages.
+   *
+   * @return string[]
+   *   The paths of the core Composer packages, keyed by parent directory name.
+   */
+  protected function getCorePackages(): array {
+    $workspace_dir = $this->getWorkspaceDirectory();
+
+    $packages = [
+      'core' => "$workspace_dir/core",
+    ];
+    foreach (['Metapackage', 'Plugin'] as $type) {
+      foreach (Composer::composerSubprojectPaths($workspace_dir, $type) as $package) {
+        $path = $package->getPath();
+        $name = basename($path);
+        $packages[$name] = $path;
+      }
+    }
+    return $packages;
+  }
+
+  /**
+   * Creates a test project from a given template and installs Drupal.
+   *
+   * @param string $template
+   *   The template to use. Can be 'RecommendedProject' or 'LegacyProject'.
+   */
+  protected function createTestProject(string $template): void {
+    // Create a copy of core (including its Composer plugins, templates, and
+    // metapackages) which we can modify.
+    $this->copyCodebase();
+
+    $workspace_dir = $this->getWorkspaceDirectory();
+    $template_dir = "composer/Template/$template";
+
+    // Remove the packages.drupal.org entry (and any other custom repository)
+    // from the template's repositories section. We have no reliable way of
+    // knowing the repositories' names in advance, so we get that information
+    // from `composer config`, and use `composer config --unset` to actually
+    // modify the template, to ensure it's done correctly.
+    $repositories = $this->runComposer('composer config repo', $template_dir, TRUE);
+
+    foreach (array_keys($repositories) as $name) {
+      $this->runComposer("composer config --unset repo.$name", $template_dir);
+    }
+
+    // Add all core plugins and metapackages as path repositories. To disable
+    // symlinking, we need to pass the JSON representations of the repositories
+    // to `composer config`.
+    foreach ($this->getCorePackages() as $name => $path) {
+      $repository = [
+        'type' => 'path',
+        'url' => $path,
+        'options' => [
+          'symlink' => FALSE,
+        ],
+      ];
+      $repository = json_encode($repository, JSON_UNESCAPED_SLASHES);
+      $this->runComposer("composer config repo.$name '$repository'", $template_dir);
+    }
+
+    // Add a local Composer repository with all third-party dependencies.
+    $vendor = "$workspace_dir/vendor.json";
+    file_put_contents($vendor, json_encode($this->createVendorRepository(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
+    $this->runComposer("composer config repo.vendor composer file://$vendor", $template_dir);
+
+    // Disable Packagist entirely so that we don't test the Internet.
+    $this->runComposer('composer config repo.packagist.org false', $template_dir);
+
+    // Create the test project, defining its repository as part of the
+    // `composer create-project` command.
+    $repository = [
+      'type' => 'path',
+      'url' => $template_dir,
+    ];
+    $command = sprintf(
+      "COMPOSER_MIRROR_PATH_REPOS=1 composer create-project %s project --stability dev --repository '%s'",
+      $this->runComposer('composer config name', $template_dir),
+      json_encode($repository, JSON_UNESCAPED_SLASHES)
+    );
+    // Because we set the COMPOSER_MIRROR_PATH_REPOS=1 environment variable when
+    // creating the project, none of the dependencies should be symlinked.
+    $this->assertStringNotContainsString('Symlinking', $this->runComposer($command));
+
+    // If using the drupal/recommended-project template, we don't expect there
+    // to be an .htaccess file at the project root. One would normally be
+    // generated by Composer when Package Manager or other code creates a
+    // ComposerUtility object in the active directory, except that Package
+    // Manager takes specific steps to prevent that. So, here we're just
+    // confirming that, in fact, Composer's .htaccess protection was disabled.
+    // We don't do this for the drupal/legacy-project template because its
+    // project root, which is also the document root, SHOULD contain a .htaccess
+    // generated by Drupal core.
+    // We do this check because this test uses PHP's built-in web server, which
+    // ignores .htaccess files and everything in them, so a Composer-generated
+    // .htaccess file won't cause this test to fail.
+    if ($template === 'RecommendedProject') {
+      $this->assertFileDoesNotExist("$workspace_dir/project/.htaccess");
+    }
+
+    // Now that we know the project was created successfully, we can set the
+    // web root with confidence.
+    $this->webRoot = 'project/' . $this->runComposer('composer config extra.drupal-scaffold.locations.web-root', 'project');
+  }
+
+  /**
+   * Creates a Composer repository for all installed third-party dependencies.
+   *
+   * @return array
+   *   The data that should be written to the repository file.
+   */
+  protected function createVendorRepository(): array {
+    $packages = [];
+    $drupal_root = $this->getDrupalRoot();
+
+    foreach ($this->getPackagesFromLockFile() as $package) {
+      $name = $package['name'];
+      $path = "$drupal_root/vendor/$name";
+
+      // We are building a set of path repositories to projects in the vendor
+      // directory, so we will skip any project that does not exist in vendor.
+      // Also skip the projects that are symlinked in vendor. These are in our
+      // metapackage and will be represented as path repositories in the test
+      // project's composer.json.
+      if (is_dir($path) && !is_link($path)) {
+        unset(
+          // Force the package to be installed from our 'dist' information.
+          $package['source'],
+          // Don't notify anybody that we're installing this package.
+          $package['notification-url'],
+          // Since Drupal 9 requires PHP 7.3 or later, these polyfills won't be
+          // installed, so we should make sure that they're not required by
+          // anything.
+          $package['require']['symfony/polyfill-php72'],
+          $package['require']['symfony/polyfill-php73']
+        );
+        // Disabling symlinks in the transport options doesn't seem to have an
+        // effect, so we use the COMPOSER_MIRROR_PATH_REPOS environment variable
+        // to force mirroring in ::createTestProject().
+        $package['dist'] = [
+          'type' => 'path',
+          'url' => $path,
+        ];
+        $version = $package['version'];
+        $packages[$name][$version] = $package;
+      }
+    }
+    return ['packages' => $packages];
+  }
+
+  /**
+   * Returns all package information from the lock file.
+   *
+   * @return array[]
+   *   All package data from the lock file.
+   */
+  private function getPackagesFromLockFile(): array {
+    $lock = $this->getDrupalRoot() . '/composer.lock';
+    $this->assertFileExists($lock);
+
+    $lock = file_get_contents($lock);
+    $lock = json_decode($lock, TRUE, JSON_THROW_ON_ERROR);
+
+    $lock += [
+      'packages' => [],
+      'packages-dev' => [],
+    ];
+    return array_merge($lock['packages'], $lock['packages-dev']);
+  }
+
+  /**
+   * Runs a Composer command and returns its output.
+   *
+   * Always asserts that the command was executed successfully.
+   *
+   * @param string $command
+   *   The command to execute, including the `composer` invocation.
+   * @param string $working_dir
+   *   (optional) A working directory relative to the workspace, within which to
+   *   execute the command. Defaults to the workspace directory.
+   * @param bool $json
+   *   (optional) Whether to parse the command's output as JSON before returning
+   *   it. Defaults to FALSE.
+   *
+   * @return mixed|string|null
+   *   The command's output, optionally parsed as JSON.
+   */
+  protected function runComposer(string $command, string $working_dir = NULL, bool $json = FALSE) {
+    $output = $this->executeCommand($command, $working_dir)->getOutput();
+    $this->assertCommandSuccessful();
+
+    $output = trim($output);
+    if ($json) {
+      $output = json_decode($output, TRUE, JSON_THROW_ON_ERROR);
+    }
+    return $output;
+  }
+
+}
diff --git a/core/modules/auto_updates/tests/src/Build/UpdateTestBase.php b/core/modules/auto_updates/tests/src/Build/UpdateTestBase.php
index 7bd97a62b0ad..dae6215766f1 100644
--- a/core/modules/auto_updates/tests/src/Build/UpdateTestBase.php
+++ b/core/modules/auto_updates/tests/src/Build/UpdateTestBase.php
@@ -2,22 +2,12 @@
 
 namespace Drupal\Tests\auto_updates\Build;
 
-use Drupal\BuildTests\QuickStart\QuickStartTestBase;
-use Drupal\Component\Serialization\Json;
 use Drupal\Component\Utility\Html;
-use Drupal\Tests\auto_updates\Traits\LocalPackagesTrait;
-use Drupal\Tests\auto_updates\Traits\SettingsTrait;
 
 /**
  * Base class for tests that perform in-place updates.
  */
-abstract class UpdateTestBase extends QuickStartTestBase {
-
-  use LocalPackagesTrait {
-    getPackagePath as traitGetPackagePath;
-    copyPackage as traitCopyPackage;
-  }
-  use SettingsTrait;
+abstract class UpdateTestBase extends TemplateProjectSiteTestBase {
 
   /**
    * A secondary server instance, to serve XML metadata about available updates.
@@ -26,15 +16,6 @@ abstract class UpdateTestBase extends QuickStartTestBase {
    */
   private $metadataServer;
 
-  /**
-   * The test site's document root, relative to the workspace directory.
-   *
-   * @var string
-   *
-   * @see ::createTestSite()
-   */
-  private $webRoot;
-
   /**
    * {@inheritdoc}
    */
@@ -48,36 +29,54 @@ protected function tearDown(): void {
   /**
    * {@inheritdoc}
    */
-  protected function copyPackage(string $source_dir, string $destination_dir = NULL): string {
-    return $this->traitCopyPackage($source_dir, $destination_dir ?: $this->getWorkspaceDirectory());
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  protected function getPackagePath(array $package): string {
-    if ($package['name'] === 'drupal/core') {
-      return 'core';
-    }
+  protected function createTestProject(string $template): void {
+    parent::createTestProject($template);
 
-    [$vendor, $name] = explode('/', $package['name']);
+    // Install Automatic Updates into the test project and ensure it wasn't
+    // symlinked.
+    $dir = 'project';
+    $this->runComposer('composer config repo.auto_updates path ' . __DIR__ . '/../../..', $dir);
+    $this->runComposer('composer require --no-update "drupal/auto_updates:@dev"', $dir);
+    $output = $this->runComposer('COMPOSER_MIRROR_PATH_REPOS=1 composer update --with-all-dependencies', $dir);
+    $this->assertStringNotContainsString('Symlinking', $output);
 
-    // Assume any contributed module is in modules/contrib/$name.
-    if ($vendor === 'drupal' && $package['type'] === 'drupal-module') {
-      return implode(DIRECTORY_SEPARATOR, ['modules', 'contrib', $name]);
-    }
-
-    return $this->traitGetPackagePath($package);
+    // Install Drupal. Always allow test modules to be installed in the UI and,
+    // for easier debugging, always display errors in their dubious glory.
+    $this->installQuickStart('minimal');
+    $php = <<<END
+\$settings['extension_discovery_scan_tests'] = TRUE;
+\$config['system.logging']['error_level'] = 'verbose';
+END;
+    $this->writeSettings($php);
+
+    // Install Automatic Updates and other modules needed for testing.
+    $this->formLogin($this->adminUsername, $this->adminPassword);
+    $this->installModules([
+      'auto_updates',
+      'auto_updates_test',
+      'update_test',
+    ]);
   }
 
   /**
-   * Returns the full path to the test site's document root.
+   * Appends PHP code to the test site's settings.php.
    *
-   * @return string
-   *   The full path of the test site's document root.
-   */
-  protected function getWebRoot(): string {
-    return $this->getWorkspaceDirectory() . DIRECTORY_SEPARATOR . $this->webRoot;
+   * @param string $php
+   *   The PHP code to append to the test site's settings.php.
+   */
+  protected function writeSettings(string $php): void {
+    // Ensure settings are writable, since this is the only way we can set
+    // configuration values that aren't accessible in the UI.
+    $file = $this->getWebRoot() . '/sites/default/settings.php';
+    $this->assertFileExists($file);
+    chmod(dirname($file), 0744);
+    chmod($file, 0744);
+    $this->assertFileIsWritable($file);
+
+    $stream = fopen($file, 'a');
+    $this->assertIsResource($stream);
+    $this->assertIsInt(fwrite($stream, $php));
+    $this->assertTrue(fclose($stream));
   }
 
   /**
@@ -100,161 +99,12 @@ protected function setReleaseMetadata(array $xml_map): void {
     // about available updates.
     if (empty($this->metadataServer)) {
       $port = $this->findAvailablePort();
-      $this->metadataServer = $this->instantiateServer($port, $this->webRoot);
+      $this->metadataServer = $this->instantiateServer($port);
       $code .= <<<END
 \$config['update.settings']['fetch']['url'] = 'http://localhost:$port/automatic-update-test';
 END;
     }
-    $this->addSettings($code, $this->getWebRoot());
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function visit($request_uri = '/', $working_dir = NULL) {
-    return parent::visit($request_uri, $working_dir ?: $this->webRoot);
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function formLogin($username, $password, $working_dir = NULL) {
-    parent::formLogin($username, $password, $working_dir ?: $this->webRoot);
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function installQuickStart($profile, $working_dir = NULL) {
-    parent::installQuickStart($profile, $working_dir ?: $this->webRoot);
-
-    // Always allow test modules to be installed in the UI and, for easier
-    // debugging, always display errors in their dubious glory.
-    $php = <<<END
-\$settings['extension_discovery_scan_tests'] = TRUE;
-\$config['system.logging']['error_level'] = 'verbose';
-END;
-    $this->addSettings($php, $this->getWebRoot());
-  }
-
-  /**
-   * Uses our already-installed dependencies to build a test site to update.
-   *
-   * @param string $template
-   *   The template project from which to build the test site. Can be
-   *   'drupal/recommended-project' or 'drupal/legacy-project'.
-   */
-  protected function createTestSite(string $template): void {
-    // Create the test site using one of the core project templates, but don't
-    // install dependencies just yet.
-    $template_dir = implode(DIRECTORY_SEPARATOR, [
-      $this->getDrupalRoot(),
-      'composer',
-      'Template',
-    ]);
-    $recommended_template = $this->createPathRepository($template_dir . DIRECTORY_SEPARATOR . 'RecommendedProject');
-    $legacy_template = $this->createPathRepository($template_dir . DIRECTORY_SEPARATOR . 'LegacyProject');
-
-    $dir = $this->getWorkspaceDirectory();
-    $command = sprintf(
-      "composer create-project %s %s --no-install --stability dev --repository '%s' --repository '%s'",
-      $template,
-      $dir,
-      Json::encode($recommended_template),
-      Json::encode($legacy_template)
-    );
-    $this->executeCommand($command);
-    $this->assertCommandSuccessful();
-
-    $composer = $dir . DIRECTORY_SEPARATOR . 'composer.json';
-    $data = $this->readJson($composer);
-
-    // Allow the test to configure the test site as necessary.
-    $data = $this->getInitialConfiguration($data);
-
-    // We need to know the path of the web root, relative to the project root,
-    // in order to install Drupal or visit the test site at all. Luckily, both
-    // template projects define this because the scaffold plugin needs to know
-    // it as well.
-    // @see ::visit()
-    // @see ::formLogin()
-    // @see ::installQuickStart()
-    $this->webRoot = $data['extra']['drupal-scaffold']['locations']['web-root'];
-
-    // Update the test site's composer.json.
-    $this->writeJson($composer, $data);
-    // Install dependencies, including dev.
-    $this->executeCommand('composer install');
-    $this->assertCommandSuccessful();
-  }
-
-  /**
-   * Returns the initial data to write to the test site's composer.json.
-   *
-   * This configuration will be used to build the pre-update test site.
-   *
-   * @param array $data
-   *   The current contents of the test site's composer.json.
-   *
-   * @return array
-   *   The data that should be written to the test site's composer.json.
-   */
-  protected function getInitialConfiguration(array $data): array {
-    $drupal_root = $this->getDrupalRoot();
-    $core_composer_dir = $drupal_root . DIRECTORY_SEPARATOR . 'composer';
-    $repositories = [];
-
-    // Add all the metapackages that are provided by Drupal core.
-    $metapackage_dir = $core_composer_dir . DIRECTORY_SEPARATOR . 'Metapackage';
-    $repositories['drupal/core-recommended'] = $this->createPathRepository($metapackage_dir . DIRECTORY_SEPARATOR . 'CoreRecommended');
-    $repositories['drupal/core-dev'] = $this->createPathRepository($metapackage_dir . DIRECTORY_SEPARATOR . 'DevDependencies');
-
-    // Add all the Composer plugins that are provided by Drupal core.
-    $plugin_dir = $core_composer_dir . DIRECTORY_SEPARATOR . 'Plugin';
-    $repositories['drupal/core-project-message'] = $this->createPathRepository($plugin_dir . DIRECTORY_SEPARATOR . 'ProjectMessage');
-    $repositories['drupal/core-composer-scaffold'] = $this->createPathRepository($plugin_dir . DIRECTORY_SEPARATOR . 'Scaffold');
-    $repositories['drupal/core-vendor-hardening'] = $this->createPathRepository($plugin_dir . DIRECTORY_SEPARATOR . 'VendorHardening');
-
-    $repositories = array_merge($repositories, $this->getLocalPackageRepositories($drupal_root));
-    // To ensure the test runs entirely offline, don't allow Composer to contact
-    // Packagist.
-    $repositories['packagist.org'] = FALSE;
-
-    $repositories['drupal/auto_updates'] = [
-      'type' => 'path',
-      'url' => __DIR__ . '/../../..',
-    ];
-    // Use whatever the current branch of auto_updates is.
-    $data['require']['drupal/auto_updates'] = '*';
-
-    $data['repositories'] = $repositories;
-
-    // Since Drupal 9 requires PHP 7.3 or later, these packages are probably
-    // not installed, which can cause trouble during dependency resolution.
-    // The drupal/drupal package (defined with a composer.json that is part
-    // of core's repository) replaces these, so we need to emulate that here.
-    $data['replace']['symfony/polyfill-php72'] = '*';
-    $data['replace']['symfony/polyfill-php73'] = '*';
-
-    return $data;
-  }
-
-  /**
-   * Asserts that a specific version of Drupal core is running.
-   *
-   * Assumes that a user with permission to view the status report is logged in.
-   *
-   * @param string $expected_version
-   *   The version of core that should be running.
-   */
-  protected function assertCoreVersion(string $expected_version): void {
-    $this->visit('/admin/reports/status');
-    $item = $this->getMink()
-      ->assertSession()
-      ->elementExists('css', 'h3:contains("Drupal Version")')
-      ->getParent()
-      ->getText();
-    $this->assertStringContainsString($expected_version, $item);
+    $this->writeSettings($code);
   }
 
   /**
diff --git a/core/modules/auto_updates/tests/src/Functional/UpdaterFormTest.php b/core/modules/auto_updates/tests/src/Functional/UpdaterFormTest.php
index 0a942b4ececf..764733da649e 100644
--- a/core/modules/auto_updates/tests/src/Functional/UpdaterFormTest.php
+++ b/core/modules/auto_updates/tests/src/Functional/UpdaterFormTest.php
@@ -282,7 +282,10 @@ private function assertUpdateStagedTimes(int $attempted_times): void {
 
     /** @var \Drupal\package_manager_bypass\InvocationRecorderBase $stager */
     $stager = $this->container->get('package_manager.stager');
-    $this->assertCount($attempted_times, $stager->getInvocationArguments());
+    // If an update was attempted, then there will be two calls to the stager:
+    // one to change the constraints in composer.json, and another to actually
+    // update the installed dependencies.
+    $this->assertCount($attempted_times * 2, $stager->getInvocationArguments());
 
     /** @var \Drupal\package_manager_bypass\InvocationRecorderBase $committer */
     $committer = $this->container->get('package_manager.committer');
diff --git a/core/modules/auto_updates/tests/src/Kernel/CronUpdaterTest.php b/core/modules/auto_updates/tests/src/Kernel/CronUpdaterTest.php
index 52088d6a674c..72f12cec97a4 100644
--- a/core/modules/auto_updates/tests/src/Kernel/CronUpdaterTest.php
+++ b/core/modules/auto_updates/tests/src/Kernel/CronUpdaterTest.php
@@ -110,7 +110,10 @@ public function testUpdaterCalled(string $setting, string $release_data, bool $w
 
     $will_update = (int) $will_update;
     $this->assertCount($will_update, $this->container->get('package_manager.beginner')->getInvocationArguments());
-    $this->assertCount($will_update, $this->container->get('package_manager.stager')->getInvocationArguments());
+    // If updates happen, then there will be two calls to the stager: one to
+    // change the constraints in composer.json, and another to actually update
+    // the installed dependencies.
+    $this->assertCount($will_update * 2, $this->container->get('package_manager.stager')->getInvocationArguments());
     $this->assertCount($will_update, $this->container->get('package_manager.committer')->getInvocationArguments());
   }
 
diff --git a/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/StagedProjectsValidatorTest.php b/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/StagedProjectsValidatorTest.php
index 7c07a2b9731e..c84e7c5e7a66 100644
--- a/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/StagedProjectsValidatorTest.php
+++ b/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/StagedProjectsValidatorTest.php
@@ -64,6 +64,7 @@ private function validate(string $active_dir, string $stage_dir): array {
 
     $locator->getActiveDirectory()->willReturn($active_dir);
     $locator->getProjectRoot()->willReturn($active_dir);
+    $locator->getWebRoot()->willReturn('');
     $locator->getVendorDirectory()->willReturn($active_dir);
 
     $stage_dir_exists = is_dir($stage_dir);
diff --git a/core/modules/auto_updates/tests/src/Kernel/UpdaterTest.php b/core/modules/auto_updates/tests/src/Kernel/UpdaterTest.php
index ad7e46193b85..974de5cc7052 100644
--- a/core/modules/auto_updates/tests/src/Kernel/UpdaterTest.php
+++ b/core/modules/auto_updates/tests/src/Kernel/UpdaterTest.php
@@ -51,6 +51,7 @@ public function testCorrectVersionsStaged() {
     $locator = $this->prophesize(PathLocator::class);
     $locator->getActiveDirectory()->willReturn($fixture_dir);
     $locator->getProjectRoot()->willReturn($fixture_dir);
+    $locator->getWebRoot()->willReturn('');
     $locator->getVendorDirectory()->willReturn($fixture_dir);
     $this->container->set('package_manager.path_locator', $locator->reveal());
 
@@ -74,26 +75,29 @@ public function testCorrectVersionsStaged() {
     // The production dependencies should be updated first...
     $expected_require_arguments = [
       'require',
+      '--no-update',
       'drupal/core-recommended:9.8.1',
-      '--update-with-all-dependencies',
     ];
     // ...followed by the dev dependencies.
     $expected_require_dev_arguments = [
       'require',
+      '--no-update',
       'drupal/core-dev:9.8.1',
-      '--update-with-all-dependencies',
       '--dev',
     ];
+    // In both cases, `composer update` will be called separately.
+    $expected_update_arguments = ['update', '--with-all-dependencies'];
+
     $this->container->get('auto_updates.updater')->claim($id)->stage();
 
     /** @var \Drupal\package_manager_bypass\InvocationRecorderBase $stager */
     $stager = $this->container->get('package_manager.stager');
-    [
-      $actual_require_arguments,
-      $actual_require_dev_arguments,
-    ] = $stager->getInvocationArguments();
-    $this->assertSame($expected_require_arguments, $actual_require_arguments[0]);
-    $this->assertSame($expected_require_dev_arguments, $actual_require_dev_arguments[0]);
+    $invocation_arguments = $stager->getInvocationArguments();
+    $this->assertCount(4, $invocation_arguments);
+    $this->assertSame($expected_require_arguments, $invocation_arguments[0][0]);
+    $this->assertSame($expected_update_arguments, $invocation_arguments[1][0]);
+    $this->assertSame($expected_require_dev_arguments, $invocation_arguments[2][0]);
+    $this->assertSame($expected_update_arguments, $invocation_arguments[3][0]);
   }
 
   /**
diff --git a/core/modules/auto_updates/tests/src/Traits/JsonTrait.php b/core/modules/auto_updates/tests/src/Traits/JsonTrait.php
deleted file mode 100644
index 3c89c720bbe6..000000000000
--- a/core/modules/auto_updates/tests/src/Traits/JsonTrait.php
+++ /dev/null
@@ -1,41 +0,0 @@
-<?php
-
-namespace Drupal\Tests\auto_updates\Traits;
-
-use Drupal\Component\Serialization\Json;
-use PHPUnit\Framework\Assert;
-
-/**
- * Provides assertive methods to read and write JSON data in files.
- */
-trait JsonTrait {
-
-  /**
-   * Reads JSON data from a file and returns it as an array.
-   *
-   * @param string $path
-   *   The path of the file to read.
-   *
-   * @return mixed[]
-   *   The parsed data in the file.
-   */
-  protected function readJson(string $path): array {
-    Assert::assertIsReadable($path);
-    $data = file_get_contents($path);
-    return Json::decode($data);
-  }
-
-  /**
-   * Writes an array of data to a file as JSON.
-   *
-   * @param string $path
-   *   The path of the file to write.
-   * @param array $data
-   *   The data to be written.
-   */
-  protected function writeJson(string $path, array $data): void {
-    Assert::assertIsWritable(file_exists($path) ? $path : dirname($path));
-    file_put_contents($path, Json::encode($data));
-  }
-
-}
diff --git a/core/modules/auto_updates/tests/src/Traits/LocalPackagesTrait.php b/core/modules/auto_updates/tests/src/Traits/LocalPackagesTrait.php
deleted file mode 100644
index 6b2d0407270e..000000000000
--- a/core/modules/auto_updates/tests/src/Traits/LocalPackagesTrait.php
+++ /dev/null
@@ -1,163 +0,0 @@
-<?php
-
-namespace Drupal\Tests\auto_updates\Traits;
-
-use Drupal\Component\FileSystem\FileSystem;
-use Drupal\Component\Utility\NestedArray;
-use PHPUnit\Framework\Assert;
-use Symfony\Component\Filesystem\Filesystem as SymfonyFilesystem;
-
-/**
- * Provides methods for interacting with installed Composer packages.
- */
-trait LocalPackagesTrait {
-
-  use JsonTrait;
-
-  /**
-   * The paths of temporary copies of packages.
-   *
-   * @see ::copyPackage()
-   * @see ::deleteCopiedPackages()
-   *
-   * @var string[]
-   */
-  private $copiedPackages = [];
-
-  /**
-   * Returns the path of an installed package, relative to composer.json.
-   *
-   * @param array $package
-   *   The package information, as read from the lock file.
-   *
-   * @return string
-   *   The path of the installed package, relative to composer.json.
-   */
-  protected function getPackagePath(array $package): string {
-    return 'vendor' . DIRECTORY_SEPARATOR . $package['name'];
-  }
-
-  /**
-   * Deletes all copied packages.
-   *
-   * @see ::copyPackage()
-   */
-  protected function deleteCopiedPackages(): void {
-    (new SymfonyFilesystem())->remove($this->copiedPackages);
-  }
-
-  /**
-   * Copies a package's entire directory to another location.
-   *
-   * The copies' paths will be stored so that they can be easily deleted by
-   * ::deleteCopiedPackages().
-   *
-   * @param string $source_dir
-   *   The path of the package directory to copy.
-   * @param string|null $destination_dir
-   *   (optional) The directory to which the package should be copied. Will be
-   *   suffixed with a random string to ensure uniqueness. If not given, the
-   *   system temporary directory will be used.
-   *
-   * @return string
-   *   The path of the temporary copy.
-   *
-   * @see ::deleteCopiedPackages()
-   */
-  protected function copyPackage(string $source_dir, string $destination_dir = NULL): string {
-    Assert::assertDirectoryExists($source_dir);
-
-    if (empty($destination_dir)) {
-      $destination_dir = FileSystem::getOsTemporaryDirectory();
-      Assert::assertNotEmpty($destination_dir);
-      $destination_dir .= DIRECTORY_SEPARATOR;
-    }
-    $destination_dir = uniqid($destination_dir);
-    Assert::assertDirectoryDoesNotExist($destination_dir);
-
-    (new SymfonyFilesystem())->mirror($source_dir, $destination_dir);
-    array_push($this->copiedPackages, $destination_dir);
-
-    return $destination_dir;
-  }
-
-  /**
-   * Generates local path repositories for a set of installed packages.
-   *
-   * @param string $dir
-   *   The directory which contains composer.lock.
-   *
-   * @return mixed[][]
-   *   The local path repositories' configuration, for inclusion in a
-   *   composer.json file.
-   */
-  protected function getLocalPackageRepositories(string $dir): array {
-    $repositories = [];
-
-    foreach ($this->getPackagesFromLockFile($dir) as $package) {
-      // Ensure the package directory is writable, since we'll need to make a
-      // few changes to it.
-      $path = $dir . DIRECTORY_SEPARATOR . $this->getPackagePath($package);
-      Assert::assertIsWritable($path);
-      $composer = $path . DIRECTORY_SEPARATOR . 'composer.json';
-
-      // Overwrite the composer.json with the fully resolved package information
-      // from the lock file.
-      // @todo Back up composer.json before overwriting it?
-      $this->writeJson($composer, $package);
-
-      $name = $package['name'];
-      $repositories[$name] = $this->createPathRepository($path);
-    }
-    return $repositories;
-  }
-
-  /**
-   * Defines a local path repository for a given path.
-   *
-   * @param string $path
-   *   The path of the repository.
-   *
-   * @return array
-   *   The local path repository definition.
-   */
-  protected function createPathRepository(string $path): array {
-    return [
-      'type' => 'path',
-      'url' => $path,
-      'options' => [
-        'symlink' => FALSE,
-      ],
-    ];
-  }
-
-  /**
-   * Alters a package's composer.json file.
-   *
-   * @param string $package_dir
-   *   The package directory.
-   * @param array $changes
-   *   The changes to merge into composer.json.
-   */
-  protected function alterPackage(string $package_dir, array $changes): void {
-    $composer = $package_dir . DIRECTORY_SEPARATOR . 'composer.json';
-    $data = $this->readJson($composer);
-    $data = NestedArray::mergeDeep($data, $changes);
-    $this->writeJson($composer, $data);
-  }
-
-  /**
-   * Reads all package information from a composer.lock file.
-   *
-   * @param string $dir
-   *   The directory which contains the lock file.
-   *
-   * @return mixed[][]
-   *   All package information (including dev packages) from the lock file.
-   */
-  private function getPackagesFromLockFile(string $dir): array {
-    $lock = $this->readJson($dir . DIRECTORY_SEPARATOR . 'composer.lock');
-    return array_merge($lock['packages'], $lock['packages-dev']);
-  }
-
-}
diff --git a/core/modules/auto_updates/tests/src/Traits/SettingsTrait.php b/core/modules/auto_updates/tests/src/Traits/SettingsTrait.php
deleted file mode 100644
index f1c1919b9b28..000000000000
--- a/core/modules/auto_updates/tests/src/Traits/SettingsTrait.php
+++ /dev/null
@@ -1,57 +0,0 @@
-<?php
-
-namespace Drupal\Tests\auto_updates\Traits;
-
-use PHPUnit\Framework\Assert;
-
-/**
- * Provides methods for manipulating site settings.
- */
-trait SettingsTrait {
-
-  /**
-   * Appends some PHP code to settings.php.
-   *
-   * @param string $php
-   *   The PHP code to append to settings.php.
-   * @param string $drupal_root
-   *   The path of the Drupal root.
-   * @param string $site
-   *   (optional) The name of the site whose settings.php should be amended.
-   *   Defaults to 'default'.
-   */
-  protected function addSettings(string $php, string $drupal_root, string $site = 'default'): void {
-    $settings = $this->makeSettingsWritable($drupal_root, $site);
-    $settings = fopen($settings, 'a');
-    Assert::assertIsResource($settings);
-    Assert::assertIsInt(fwrite($settings, $php));
-    Assert::assertTrue(fclose($settings));
-  }
-
-  /**
-   * Ensures that settings.php is writable.
-   *
-   * @param string $drupal_root
-   *   The path of the Drupal root.
-   * @param string $site
-   *   (optional) The name of the site whose settings should be made writable.
-   *   Defaults to 'default'.
-   *
-   * @return string
-   *   The path to settings.php for the specified site.
-   */
-  private function makeSettingsWritable(string $drupal_root, string $site = 'default'): string {
-    $settings = implode(DIRECTORY_SEPARATOR, [
-      $drupal_root,
-      'sites',
-      $site,
-      'settings.php',
-    ]);
-    chmod(dirname($settings), 0744);
-    chmod($settings, 0744);
-    Assert::assertIsWritable($settings);
-
-    return $settings;
-  }
-
-}
diff --git a/core/modules/package_manager/package_manager.services.yml b/core/modules/package_manager/package_manager.services.yml
index d2b01442f16a..f5e04442d0d4 100644
--- a/core/modules/package_manager/package_manager.services.yml
+++ b/core/modules/package_manager/package_manager.services.yml
@@ -4,8 +4,6 @@ services:
     class: Symfony\Component\Filesystem\Filesystem
   package_manager.symfony_executable_finder:
     class: Symfony\Component\Process\ExecutableFinder
-  package_manager.symfony_finder:
-    class: Symfony\Component\Finder\Finder
 
   # Basic infrastructure services.
   package_manager.process_factory:
@@ -45,8 +43,6 @@ services:
     class: PhpTuf\ComposerStager\Infrastructure\FileSyncer\PhpFileSyncer
     arguments:
       - '@package_manager.file_system'
-      - '@package_manager.symfony_finder'
-      - '@package_manager.symfony_finder'
   package_manager.file_syncer.factory:
     class: Drupal\package_manager\FileSyncerFactory
     arguments:
@@ -55,7 +51,7 @@ services:
       - '@package_manager.file_syncer.rsync'
       - '@config.factory'
   package_manager.file_syncer:
-    class: PhpTuf\ComposerStager\Infrastructure\FileSyncer\FileSyncerInterface
+    class: PhpTuf\ComposerStager\Domain\FileSyncer\FileSyncerInterface
     factory: ['@package_manager.file_syncer.factory', 'create']
 
   # Domain services.
@@ -127,10 +123,10 @@ services:
   package_manager.excluded_paths_subscriber:
     class: Drupal\package_manager\EventSubscriber\ExcludedPathsSubscriber
     arguments:
-      - '%app.root%'
       - '%site.path%'
-      - '@file_system'
+      - '@package_manager.symfony_file_system'
       - '@stream_wrapper_manager'
       - '@database'
+      - '@package_manager.path_locator'
     tags:
       - { name: event_subscriber }
diff --git a/core/modules/package_manager/src/Event/ExcludedPathsTrait.php b/core/modules/package_manager/src/Event/ExcludedPathsTrait.php
index 3648446b80f2..fc7e6904402a 100644
--- a/core/modules/package_manager/src/Event/ExcludedPathsTrait.php
+++ b/core/modules/package_manager/src/Event/ExcludedPathsTrait.php
@@ -15,12 +15,23 @@ trait ExcludedPathsTrait {
   protected $excludedPaths = [];
 
   /**
-   * Adds an absolute path to exclude from the current operation.
+   * Adds a path to exclude from the current operation.
    *
-   * @todo This should only accept paths relative to the active directory.
+   * If called on an instance of \Drupal\package_manager\Event\PreCreateEvent,
+   * excluded paths will not be copied into the staging area when the stage is
+   * created. If called on an instance of
+   * \Drupal\package_manager\Event\PreApplyEvent, excluded paths will not be
+   * deleted from the active directory when staged changes are applied. So,
+   * to ensure that a given path is never staged, but also preserved in the
+   * active directory, it should be passed to this method on both PreCreateEvent
+   * and PreApplyEvent. See
+   * \Drupal\package_manager\EventSubscriber\ExcludedPathsSubscriber for an
+   * example.
    *
    * @param string $path
-   *   The path to exclude.
+   *   The path to exclude, relative to the project root.
+   *
+   * @see \Drupal\package_manager\EventSubscriber\ExcludedPathsSubscriber
    */
   public function excludePath(string $path): void {
     $this->excludedPaths[] = $path;
diff --git a/core/modules/package_manager/src/EventSubscriber/ComposerExecutableValidator.php b/core/modules/package_manager/src/EventSubscriber/ComposerExecutableValidator.php
index a4df9a359155..f91e85293032 100644
--- a/core/modules/package_manager/src/EventSubscriber/ComposerExecutableValidator.php
+++ b/core/modules/package_manager/src/EventSubscriber/ComposerExecutableValidator.php
@@ -7,21 +7,21 @@
 use Drupal\Core\Extension\ExtensionVersion;
 use Drupal\Core\StringTranslation\StringTranslationTrait;
 use Drupal\Core\StringTranslation\TranslationInterface;
-use PhpTuf\ComposerStager\Domain\Output\ProcessOutputCallbackInterface;
+use PhpTuf\ComposerStager\Domain\Process\OutputCallbackInterface;
+use PhpTuf\ComposerStager\Domain\Process\Runner\ComposerRunnerInterface;
 use PhpTuf\ComposerStager\Exception\ExceptionInterface;
-use PhpTuf\ComposerStager\Infrastructure\Process\Runner\ComposerRunnerInterface;
 
 /**
  * Validates that the Composer executable can be found in the correct version.
  */
-class ComposerExecutableValidator implements PreOperationStageValidatorInterface, ProcessOutputCallbackInterface {
+class ComposerExecutableValidator implements PreOperationStageValidatorInterface, OutputCallbackInterface {
 
   use StringTranslationTrait;
 
   /**
    * The Composer runner.
    *
-   * @var \PhpTuf\ComposerStager\Infrastructure\Process\Runner\ComposerRunnerInterface
+   * @var \PhpTuf\ComposerStager\Domain\Process\Runner\ComposerRunnerInterface
    */
   protected $composer;
 
@@ -35,7 +35,7 @@ class ComposerExecutableValidator implements PreOperationStageValidatorInterface
   /**
    * Constructs a ComposerExecutableValidator object.
    *
-   * @param \PhpTuf\ComposerStager\Infrastructure\Process\Runner\ComposerRunnerInterface $composer
+   * @param \PhpTuf\ComposerStager\Domain\Process\Runner\ComposerRunnerInterface $composer
    *   The Composer runner.
    * @param \Drupal\Core\StringTranslation\TranslationInterface $translation
    *   The translation service.
diff --git a/core/modules/package_manager/src/EventSubscriber/ExcludedPathsSubscriber.php b/core/modules/package_manager/src/EventSubscriber/ExcludedPathsSubscriber.php
index 4663284c7963..b65ff7a70921 100644
--- a/core/modules/package_manager/src/EventSubscriber/ExcludedPathsSubscriber.php
+++ b/core/modules/package_manager/src/EventSubscriber/ExcludedPathsSubscriber.php
@@ -3,25 +3,20 @@
 namespace Drupal\package_manager\EventSubscriber;
 
 use Drupal\Core\Database\Connection;
-use Drupal\Core\File\FileSystemInterface;
 use Drupal\Core\StreamWrapper\LocalStream;
 use Drupal\Core\StreamWrapper\StreamWrapperManagerInterface;
 use Drupal\package_manager\Event\PreApplyEvent;
 use Drupal\package_manager\Event\PreCreateEvent;
+use Drupal\package_manager\Event\StageEvent;
+use Drupal\package_manager\PathLocator;
 use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+use Symfony\Component\Filesystem\Filesystem;
 
 /**
  * Defines an event subscriber to exclude certain paths from staging areas.
  */
 class ExcludedPathsSubscriber implements EventSubscriberInterface {
 
-  /**
-   * The Drupal root.
-   *
-   * @var string
-   */
-  protected $appRoot;
-
   /**
    * The current site path, relative to the Drupal root.
    *
@@ -30,9 +25,9 @@ class ExcludedPathsSubscriber implements EventSubscriberInterface {
   protected $sitePath;
 
   /**
-   * The file system service.
+   * The Symfony file system service.
    *
-   * @var \Drupal\Core\File\FileSystemInterface
+   * @var \Symfony\Component\Filesystem\Filesystem
    */
   protected $fileSystem;
 
@@ -50,91 +45,157 @@ class ExcludedPathsSubscriber implements EventSubscriberInterface {
    */
   protected $database;
 
+  /**
+   * The path locator service.
+   *
+   * @var \Drupal\package_manager\PathLocator
+   */
+  protected $pathLocator;
+
   /**
    * Constructs an ExcludedPathsSubscriber.
    *
-   * @param string $app_root
-   *   The Drupal root.
    * @param string $site_path
    *   The current site path, relative to the Drupal root.
-   * @param \Drupal\Core\File\FileSystemInterface $file_system
-   *   The file system service.
+   * @param \Symfony\Component\Filesystem\Filesystem $file_system
+   *   The Symfony file system service.
    * @param \Drupal\Core\StreamWrapper\StreamWrapperManagerInterface $stream_wrapper_manager
    *   The stream wrapper manager service.
    * @param \Drupal\Core\Database\Connection $database
    *   The database connection.
+   * @param \Drupal\package_manager\PathLocator $path_locator
+   *   The path locator service.
    */
-  public function __construct(string $app_root, string $site_path, FileSystemInterface $file_system, StreamWrapperManagerInterface $stream_wrapper_manager, Connection $database) {
-    $this->appRoot = $app_root;
+  public function __construct(string $site_path, Filesystem $file_system, StreamWrapperManagerInterface $stream_wrapper_manager, Connection $database, PathLocator $path_locator) {
     $this->sitePath = $site_path;
     $this->fileSystem = $file_system;
     $this->streamWrapperManager = $stream_wrapper_manager;
     $this->database = $database;
+    $this->pathLocator = $path_locator;
   }
 
   /**
-   * Reacts before staged changes are committed the active directory.
+   * Flags paths to be excluded, relative to the web root.
    *
-   * @param \Drupal\package_manager\Event\PreApplyEvent $event
+   * This should only be used for paths that, if they exist at all, are
+   * *guaranteed* to exist within the web root.
+   *
+   * @param \Drupal\package_manager\Event\PreCreateEvent|\Drupal\package_manager\Event\PreApplyEvent $event
    *   The event object.
+   * @param string[] $paths
+   *   The paths to exclude. These should be relative to the web root, and will
+   *   be made relative to the project root.
    */
-  public function preApply(PreApplyEvent $event): void {
-    // Don't copy anything from the staging area's sites/default.
-    // @todo Make this a lot smarter in https://www.drupal.org/i/3228955.
-    $event->excludePath('sites/default');
+  protected function excludeInWebRoot(StageEvent $event, array $paths): void {
+    $web_root = $this->pathLocator->getWebRoot();
+    if ($web_root) {
+      $web_root .= '/';
+    }
 
-    // If the core-vendor-hardening plugin (used in the legacy-project template)
-    // is present, it may have written a web.config file into the vendor
-    // directory. We don't want to copy that.
-    $event->excludePath('web.config');
+    foreach ($paths as $path) {
+      // Make the path relative to the project root by prefixing the web root.
+      $event->excludePath($web_root . $path);
+    }
   }
 
   /**
-   * Excludes paths from a staging area before it is created.
+   * Flags paths to be excluded, relative to the project root.
    *
-   * @param \Drupal\package_manager\Event\PreCreateEvent $event
+   * @param \Drupal\package_manager\Event\PreCreateEvent|\Drupal\package_manager\Event\PreApplyEvent $event
    *   The event object.
+   * @param string[] $paths
+   *   The paths to exclude. Absolute paths will be made relative to the project
+   *   root; relative paths will be assumed to already be relative to the
+   *   project root, and excluded as given.
    */
-  public function preCreate(PreCreateEvent $event): void {
-    // Automated test site directories should never be staged.
-    $event->excludePath('sites/simpletest');
-
-    // Windows server configuration files, like web.config, should never be
-    // staged either. (These can be written in the vendor directory by the
-    // core-vendor-hardening plugin, which is used in the drupal/legacy-project
-    // template.)
-    $event->excludePath('web.config');
-
-    if ($public = $this->getFilesPath('public')) {
-      $event->excludePath($public);
+  protected function excludeInProjectRoot(StageEvent $event, array $paths): void {
+    $project_root = $this->pathLocator->getProjectRoot();
+
+    foreach ($paths as $path) {
+      // Make absolute paths relative to the project root.
+      $path = str_replace($project_root, NULL, $path);
+      $path = ltrim($path, '/');
+      $event->excludePath($path);
     }
-    if ($private = $this->getFilesPath('private')) {
-      $event->excludePath($private);
+  }
+
+  /**
+   * Excludes common paths from staging operations.
+   *
+   * @param \Drupal\package_manager\Event\PreApplyEvent|\Drupal\package_manager\Event\PreCreateEvent $event
+   *   The event object.
+   *
+   * @see \Drupal\package_manager\Event\ExcludedPathsTrait::excludePath()
+   */
+  public function ignoreCommonPaths(StageEvent $event): void {
+    // Compile two lists of paths to exclude: paths that are relative to the
+    // project root, and paths that are relative to the web root.
+    $web = $project = [];
+
+    // Always ignore automated test directories. If they exist, they will be in
+    // the web root.
+    $web[] = 'sites/simpletest';
+
+    // If the core-vendor-hardening plugin (used in the legacy-project template)
+    // is present, it may have written security hardening files in the vendor
+    // directory. They should always be ignored.
+    $vendor_dir = $this->pathLocator->getVendorDirectory();
+    $project[] = $vendor_dir . '/web.config';
+    $project[] = $vendor_dir . '/.htaccess';
+
+    // Ignore public and private files. These paths could be either absolute or
+    // relative, depending on site settings. If they are absolute, treat them
+    // as relative to the project root. Otherwise, treat them as relative to
+    // the web root.
+    $files = array_filter([
+      $this->getFilesPath('public'),
+      $this->getFilesPath('private'),
+    ]);
+    foreach ($files as $path) {
+      if ($this->fileSystem->isAbsolutePath($path)) {
+        $project[] = $path;
+      }
+      else {
+        $web[] = $path;
+      }
     }
 
-    // Exclude site-specific settings files.
+    // Ignore site-specific settings files, which are always in the web root.
     $settings_files = [
       'settings.php',
       'settings.local.php',
       'services.yml',
     ];
-    $default_site = 'sites' . DIRECTORY_SEPARATOR . 'default';
-
     foreach ($settings_files as $settings_file) {
-      $event->excludePath($this->sitePath . DIRECTORY_SEPARATOR . $settings_file);
-      $event->excludePath($default_site . DIRECTORY_SEPARATOR . $settings_file);
+      $web[] = $this->sitePath . '/' . $settings_file;
+      $web[] = 'sites/default/' . $settings_file;
     }
 
     // If the database is SQLite, it might be located in the active directory
-    // and we should not stage it.
+    // and we should ignore it. Always treat it as relative to the project root.
     if ($this->database->driver() === 'sqlite') {
       $options = $this->database->getConnectionOptions();
-      $database = str_replace($this->appRoot, NULL, $options['database']);
-      $database = ltrim($database, '/');
-      $event->excludePath($database);
-      $event->excludePath("$database-shm");
-      $event->excludePath("$database-wal");
+      $project[] = $options['database'];
+      $project[] = $options['database'] . '-shm';
+      $project[] = $options['database'] . '-wal';
     }
+
+    $this->excludeInWebRoot($event, $web);
+    $this->excludeInProjectRoot($event, $project);
+  }
+
+  /**
+   * Reacts before staged changes are committed the active directory.
+   *
+   * @param \Drupal\package_manager\Event\PreApplyEvent $event
+   *   The event object.
+   */
+  public function preApply(PreApplyEvent $event): void {
+    // Don't copy anything from the staging area's sites/default.
+    // @todo Make this a lot smarter in https://www.drupal.org/i/3228955.
+    $this->excludeInWebRoot($event, ['sites/default']);
+
+    $this->ignoreCommonPaths($event);
   }
 
   /**
@@ -165,7 +226,7 @@ private function getFilesPath(string $scheme): ?string {
    */
   public static function getSubscribedEvents() {
     return [
-      PreCreateEvent::class => 'preCreate',
+      PreCreateEvent::class => 'ignoreCommonPaths',
       PreApplyEvent::class => 'preApply',
     ];
   }
diff --git a/core/modules/package_manager/src/FileSyncerFactory.php b/core/modules/package_manager/src/FileSyncerFactory.php
index 727620a24857..ee96aa1e579c 100644
--- a/core/modules/package_manager/src/FileSyncerFactory.php
+++ b/core/modules/package_manager/src/FileSyncerFactory.php
@@ -3,9 +3,9 @@
 namespace Drupal\package_manager;
 
 use Drupal\Core\Config\ConfigFactoryInterface;
-use PhpTuf\ComposerStager\Infrastructure\FileSyncer\FileSyncerFactoryInterface;
+use PhpTuf\ComposerStager\Domain\FileSyncer\FileSyncerFactoryInterface;
+use PhpTuf\ComposerStager\Domain\FileSyncer\FileSyncerInterface;
 use PhpTuf\ComposerStager\Infrastructure\FileSyncer\FileSyncerFactory as StagerFileSyncerFactory;
-use PhpTuf\ComposerStager\Infrastructure\FileSyncer\FileSyncerInterface;
 use Symfony\Component\Process\ExecutableFinder;
 
 /**
@@ -16,21 +16,21 @@ class FileSyncerFactory implements FileSyncerFactoryInterface {
   /**
    * The decorated file syncer factory.
    *
-   * @var \PhpTuf\ComposerStager\Infrastructure\FileSyncer\FileSyncerFactoryInterface
+   * @var \PhpTuf\ComposerStager\Domain\FileSyncer\FileSyncerFactoryInterface
    */
   protected $decorated;
 
   /**
    * The PHP file syncer service.
    *
-   * @var \PhpTuf\ComposerStager\Infrastructure\FileSyncer\FileSyncerInterface
+   * @var \PhpTuf\ComposerStager\Domain\FileSyncer\FileSyncerInterface
    */
   protected $phpFileSyncer;
 
   /**
    * The rsync file syncer service.
    *
-   * @var \PhpTuf\ComposerStager\Infrastructure\FileSyncer\FileSyncerInterface
+   * @var \PhpTuf\ComposerStager\Domain\FileSyncer\FileSyncerInterface
    */
   protected $rsyncFileSyncer;
 
@@ -46,9 +46,9 @@ class FileSyncerFactory implements FileSyncerFactoryInterface {
    *
    * @param \Symfony\Component\Process\ExecutableFinder $executable_finder
    *   The Symfony executable finder.
-   * @param \PhpTuf\ComposerStager\Infrastructure\FileSyncer\FileSyncerInterface $php_file_syncer
+   * @param \PhpTuf\ComposerStager\Domain\FileSyncer\FileSyncerInterface $php_file_syncer
    *   The PHP file syncer service.
-   * @param \PhpTuf\ComposerStager\Infrastructure\FileSyncer\FileSyncerInterface $rsync_file_syncer
+   * @param \PhpTuf\ComposerStager\Domain\FileSyncer\FileSyncerInterface $rsync_file_syncer
    *   The rsync file syncer service.
    * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
    *   The config factory service.
diff --git a/core/modules/package_manager/src/Stage.php b/core/modules/package_manager/src/Stage.php
index 6548db10cf65..4432089cd00f 100644
--- a/core/modules/package_manager/src/Stage.php
+++ b/core/modules/package_manager/src/Stage.php
@@ -238,14 +238,15 @@ public function create(): string {
   public function require(array $constraints, bool $dev = FALSE): void {
     $this->checkOwnership();
 
-    $command = array_merge(['require'], $constraints);
-    $command[] = '--update-with-all-dependencies';
+    $command = array_merge(['require', '--no-update'], $constraints);
     if ($dev) {
       $command[] = '--dev';
     }
 
     $this->dispatch(new PreRequireEvent($this));
-    $this->stager->stage($command, $this->getStageDirectory());
+    $dir = $this->getStageDirectory();
+    $this->stager->stage($command, $dir);
+    $this->stager->stage(['update', '--with-all-dependencies'], $dir);
     $this->dispatch(new PostRequireEvent($this));
   }
 
diff --git a/core/modules/package_manager/tests/fixtures/fake_site/vendor/.htaccess b/core/modules/package_manager/tests/fixtures/fake_site/vendor/.htaccess
new file mode 100644
index 000000000000..e11552b41d40
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/fake_site/vendor/.htaccess
@@ -0,0 +1 @@
+# This file should never be staged.
diff --git a/core/modules/package_manager/tests/modules/package_manager_bypass/src/Beginner.php b/core/modules/package_manager/tests/modules/package_manager_bypass/src/Beginner.php
index 742d38bd85dd..e9ff25509f81 100644
--- a/core/modules/package_manager/tests/modules/package_manager_bypass/src/Beginner.php
+++ b/core/modules/package_manager/tests/modules/package_manager_bypass/src/Beginner.php
@@ -3,7 +3,7 @@
 namespace Drupal\package_manager_bypass;
 
 use PhpTuf\ComposerStager\Domain\BeginnerInterface;
-use PhpTuf\ComposerStager\Domain\Output\ProcessOutputCallbackInterface;
+use PhpTuf\ComposerStager\Domain\Process\OutputCallbackInterface;
 
 /**
  * Defines an update beginner which doesn't do anything.
@@ -13,7 +13,7 @@ class Beginner extends InvocationRecorderBase implements BeginnerInterface {
   /**
    * {@inheritdoc}
    */
-  public function begin(string $activeDir, string $stagingDir, ?array $exclusions = [], ?ProcessOutputCallbackInterface $callback = NULL, ?int $timeout = 120): void {
+  public function begin(string $activeDir, string $stagingDir, ?array $exclusions = [], ?OutputCallbackInterface $callback = NULL, ?int $timeout = 120): void {
     $this->saveInvocationArguments($activeDir, $stagingDir, $exclusions);
   }
 
diff --git a/core/modules/package_manager/tests/modules/package_manager_bypass/src/Committer.php b/core/modules/package_manager/tests/modules/package_manager_bypass/src/Committer.php
index 3518237b5b1b..2feb0d0f70c1 100644
--- a/core/modules/package_manager/tests/modules/package_manager_bypass/src/Committer.php
+++ b/core/modules/package_manager/tests/modules/package_manager_bypass/src/Committer.php
@@ -3,7 +3,7 @@
 namespace Drupal\package_manager_bypass;
 
 use PhpTuf\ComposerStager\Domain\CommitterInterface;
-use PhpTuf\ComposerStager\Domain\Output\ProcessOutputCallbackInterface;
+use PhpTuf\ComposerStager\Domain\Process\OutputCallbackInterface;
 
 /**
  * Defines an update committer which doesn't do any actual committing.
@@ -30,7 +30,7 @@ public function __construct(CommitterInterface $decorated) {
   /**
    * {@inheritdoc}
    */
-  public function commit(string $stagingDir, string $activeDir, ?array $exclusions = [], ?ProcessOutputCallbackInterface $callback = NULL, ?int $timeout = 120): void {
+  public function commit(string $stagingDir, string $activeDir, ?array $exclusions = [], ?OutputCallbackInterface $callback = NULL, ?int $timeout = 120): void {
     $this->saveInvocationArguments($activeDir, $stagingDir, $exclusions);
   }
 
diff --git a/core/modules/package_manager/tests/modules/package_manager_bypass/src/Stager.php b/core/modules/package_manager/tests/modules/package_manager_bypass/src/Stager.php
index bb93e47271db..237eccab38c1 100644
--- a/core/modules/package_manager/tests/modules/package_manager_bypass/src/Stager.php
+++ b/core/modules/package_manager/tests/modules/package_manager_bypass/src/Stager.php
@@ -2,7 +2,7 @@
 
 namespace Drupal\package_manager_bypass;
 
-use PhpTuf\ComposerStager\Domain\Output\ProcessOutputCallbackInterface;
+use PhpTuf\ComposerStager\Domain\Process\OutputCallbackInterface;
 use PhpTuf\ComposerStager\Domain\StagerInterface;
 
 /**
@@ -13,7 +13,7 @@ class Stager extends InvocationRecorderBase implements StagerInterface {
   /**
    * {@inheritdoc}
    */
-  public function stage(array $composerCommand, string $stagingDir, ?ProcessOutputCallbackInterface $callback = NULL, ?int $timeout = 120): void {
+  public function stage(array $composerCommand, string $stagingDir, ?OutputCallbackInterface $callback = NULL, ?int $timeout = 120): void {
     $this->saveInvocationArguments($composerCommand, $stagingDir);
   }
 
diff --git a/core/modules/package_manager/tests/src/Functional/ExcludedPathsTest.php b/core/modules/package_manager/tests/src/Functional/ExcludedPathsTest.php
deleted file mode 100644
index 29204c9d6566..000000000000
--- a/core/modules/package_manager/tests/src/Functional/ExcludedPathsTest.php
+++ /dev/null
@@ -1,155 +0,0 @@
-<?php
-
-namespace Drupal\Tests\package_manager\Functional;
-
-use Drupal\Core\Database\Driver\sqlite\Connection;
-use Drupal\Core\Site\Settings;
-use Drupal\package_manager\PathLocator;
-use Drupal\package_manager\Stage;
-use Drupal\Tests\BrowserTestBase;
-
-/**
- * @covers \Drupal\package_manager\EventSubscriber\ExcludedPathsSubscriber
- *
- * @group package_manager
- */
-class ExcludedPathsTest extends BrowserTestBase {
-
-  /**
-   * {@inheritdoc}
-   */
-  protected $defaultTheme = 'stark';
-
-  /**
-   * {@inheritdoc}
-   */
-  protected static $modules = [
-    'package_manager',
-    'package_manager_bypass',
-  ];
-
-  /**
-   * {@inheritdoc}
-   */
-  protected function prepareSettings() {
-    parent::prepareSettings();
-
-    // Disable the filesystem permissions validator, since we cannot guarantee
-    // that the current code base will be writable in all testing situations.
-    // We test this validator functionally in Automatic Updates' build tests,
-    // since those do give us control over the filesystem permissions.
-    // @see \Drupal\Tests\auto_updates\Build\CoreUpdateTest::assertReadOnlyFileSystemError()
-    // @see \Drupal\Tests\package_manager\Kernel\WritableFileSystemValidatorTest
-    $this->writeSettings([
-      'settings' => [
-        'package_manager_bypass_stager' => (object) [
-          'value' => FALSE,
-          'required' => TRUE,
-        ],
-        'package_manager_bypass_validators' => (object) [
-          'value' => ['package_manager.validator.file_system'],
-          'required' => TRUE,
-        ],
-      ],
-    ]);
-  }
-
-  /**
-   * Tests that certain paths are excluded from staging areas.
-   */
-  public function testExcludedPaths(): void {
-    $active_dir = __DIR__ . '/../../fixtures/fake_site';
-
-    $path_locator = $this->prophesize(PathLocator::class);
-    $path_locator->getActiveDirectory()->willReturn($active_dir);
-
-    $site_path = 'sites/example.com';
-
-    // Ensure that we are using directories within the fake site fixture for
-    // public and private files.
-    $settings = Settings::getAll();
-    $settings['file_public_path'] = "$site_path/files";
-    $settings['file_private_path'] = 'private';
-    new Settings($settings);
-
-    /** @var \Drupal\package_manager\EventSubscriber\ExcludedPathsSubscriber $subscriber */
-    $subscriber = $this->container->get('package_manager.excluded_paths_subscriber');
-    $reflector = new \ReflectionObject($subscriber);
-    $property = $reflector->getProperty('sitePath');
-    $property->setAccessible(TRUE);
-    $property->setValue($subscriber, $site_path);
-
-    // Mock a SQLite database connection to a file in the active directory. The
-    // file should not be staged.
-    $database = $this->prophesize(Connection::class);
-    $database->driver()->willReturn('sqlite');
-    $database->getConnectionOptions()->willReturn([
-      'database' => $site_path . '/db.sqlite',
-    ]);
-    $property = $reflector->getProperty('database');
-    $property->setAccessible(TRUE);
-    $property->setValue($subscriber, $database->reveal());
-
-    $stage = new class(
-      $path_locator->reveal(),
-      $this->container->get('package_manager.beginner'),
-      $this->container->get('package_manager.stager'),
-      $this->container->get('package_manager.committer'),
-      $this->container->get('file_system'),
-      $this->container->get('event_dispatcher'),
-      $this->container->get('tempstore.shared'),
-    ) extends Stage {
-
-      /**
-       * The directory where staging areas will be created.
-       *
-       * @var string
-       */
-      public static $stagingRoot;
-
-      /**
-       * {@inheritdoc}
-       */
-      protected static function getStagingRoot(): string {
-        return static::$stagingRoot;
-      }
-
-    };
-    $stage::$stagingRoot = $this->siteDirectory . '/stage';
-    $stage_dir = $stage::$stagingRoot . DIRECTORY_SEPARATOR . $stage->create();
-
-    $this->assertDirectoryExists($stage_dir);
-    $this->assertDirectoryDoesNotExist("$stage_dir/sites/simpletest");
-    $this->assertFileDoesNotExist("$stage_dir/vendor/web.config");
-    $this->assertDirectoryDoesNotExist("$stage_dir/$site_path/files");
-    $this->assertDirectoryDoesNotExist("$stage_dir/private");
-    $this->assertFileDoesNotExist("$stage_dir/$site_path/settings.php");
-    $this->assertFileDoesNotExist("$stage_dir/$site_path/settings.local.php");
-    $this->assertFileDoesNotExist("$stage_dir/$site_path/services.yml");
-    // SQLite databases and their support files should never be staged.
-    $this->assertFileDoesNotExist("$stage_dir/$site_path/db.sqlite");
-    $this->assertFileDoesNotExist("$stage_dir/$site_path/db.sqlite-shm");
-    $this->assertFileDoesNotExist("$stage_dir/$site_path/db.sqlite-wal");
-    // Default site-specific settings files should never be staged.
-    $this->assertFileDoesNotExist("$stage_dir/sites/default/settings.php");
-    $this->assertFileDoesNotExist("$stage_dir/sites/default/settings.local.php");
-    $this->assertFileDoesNotExist("$stage_dir/sites/default/services.yml");
-    // A non-excluded file in the default site directory should be staged.
-    $this->assertFileExists("$stage_dir/sites/default/stage.txt");
-
-    $files = [
-      'sites/default/no-copy.txt',
-      'web.config',
-    ];
-    foreach ($files as $file) {
-      $file = "$stage_dir/$file";
-      touch($file);
-      $this->assertFileExists($file);
-    }
-    $stage->apply();
-    foreach ($files as $file) {
-      $this->assertFileDoesNotExist("$active_dir/$file");
-    }
-  }
-
-}
diff --git a/core/modules/package_manager/tests/src/Kernel/ComposerExecutableValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/ComposerExecutableValidatorTest.php
index 8905f788edda..bc57b4fa75a7 100644
--- a/core/modules/package_manager/tests/src/Kernel/ComposerExecutableValidatorTest.php
+++ b/core/modules/package_manager/tests/src/Kernel/ComposerExecutableValidatorTest.php
@@ -112,8 +112,8 @@ public function testComposerVersionValidation(string $reported_version, array $e
     // Mock the output of `composer --version`, will be passed to the validator,
     // which is itself a callback function that gets called repeatedly as
     // Composer produces output.
-    /** @var \PhpTuf\ComposerStager\Infrastructure\Process\Runner\ComposerRunnerInterface|\Prophecy\Prophecy\ObjectProphecy $runner */
-    $runner = $this->prophesize('\PhpTuf\ComposerStager\Infrastructure\Process\Runner\ComposerRunnerInterface');
+    /** @var \PhpTuf\ComposerStager\Domain\Process\Runner\ComposerRunnerInterface|\Prophecy\Prophecy\ObjectProphecy $runner */
+    $runner = $this->prophesize('\PhpTuf\ComposerStager\Domain\Process\Runner\ComposerRunnerInterface');
 
     $runner->run(['--version'], Argument::type(ComposerExecutableValidator::class))
       // Whatever is passed to ::run() will be passed to this mock callback in
diff --git a/core/modules/package_manager/tests/src/Kernel/ComposerSettingsValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/ComposerSettingsValidatorTest.php
index fae703f6e0ef..ff09f97428ae 100644
--- a/core/modules/package_manager/tests/src/Kernel/ComposerSettingsValidatorTest.php
+++ b/core/modules/package_manager/tests/src/Kernel/ComposerSettingsValidatorTest.php
@@ -85,6 +85,7 @@ public function testSecureHttpValidation(string $contents, array $expected_resul
     $locator = $this->prophesize(PathLocator::class);
     $locator->getActiveDirectory()->willReturn($active_dir);
     $locator->getProjectRoot()->willReturn($active_dir);
+    $locator->getWebRoot()->willReturn('');
     $locator->getVendorDirectory()->willReturn($active_dir);
     $this->container->set('package_manager.path_locator', $locator->reveal());
 
diff --git a/core/modules/package_manager/tests/src/Kernel/DiskSpaceValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/DiskSpaceValidatorTest.php
index ea15bb086ef4..3195b51d3a14 100644
--- a/core/modules/package_manager/tests/src/Kernel/DiskSpaceValidatorTest.php
+++ b/core/modules/package_manager/tests/src/Kernel/DiskSpaceValidatorTest.php
@@ -168,6 +168,7 @@ public function providerDiskSpaceValidation(): array {
   public function testDiskSpaceValidation(bool $shared_disk, array $free_space, array $expected_results): void {
     $path_locator = $this->prophesize('\Drupal\package_manager\PathLocator');
     $path_locator->getProjectRoot()->willReturn('root');
+    $path_locator->getWebRoot()->willReturn('');
     $path_locator->getActiveDirectory()->willReturn('root');
     $path_locator->getVendorDirectory()->willReturn('vendor');
     $this->container->set('package_manager.path_locator', $path_locator->reveal());
diff --git a/core/modules/package_manager/tests/src/Kernel/ExcludedPathsSubscriberTest.php b/core/modules/package_manager/tests/src/Kernel/ExcludedPathsSubscriberTest.php
index b44f7811d62c..0489ad421300 100644
--- a/core/modules/package_manager/tests/src/Kernel/ExcludedPathsSubscriberTest.php
+++ b/core/modules/package_manager/tests/src/Kernel/ExcludedPathsSubscriberTest.php
@@ -2,7 +2,7 @@
 
 namespace Drupal\Tests\package_manager\Kernel;
 
-use Drupal\Core\Database\Driver\sqlite\Connection;
+use Drupal\Core\Database\Connection;
 use Drupal\package_manager\Event\PreCreateEvent;
 use Drupal\package_manager\EventSubscriber\ExcludedPathsSubscriber;
 
@@ -83,15 +83,15 @@ public function testSqliteDatabaseExcluded(string $database, array $expected_exc
     $connection->getConnectionOptions()->willReturn(['database' => $database]);
 
     $subscriber = new ExcludedPathsSubscriber(
-      $this->getDrupalRoot(),
       'sites/default',
-      $this->container->get('file_system'),
+      $this->container->get('package_manager.symfony_file_system'),
       $this->container->get('stream_wrapper_manager'),
-      $connection->reveal()
+      $connection->reveal(),
+      $this->container->get('package_manager.path_locator')
     );
 
     $event = new PreCreateEvent($this->createStage());
-    $subscriber->preCreate($event);
+    $subscriber->ignoreCommonPaths($event);
     // All of the expected exclusions should be flagged.
     $this->assertEmpty(array_diff($expected_exclusions, $event->getExcludedPaths()));
   }
diff --git a/core/modules/package_manager/tests/src/Kernel/ExcludedPathsTest.php b/core/modules/package_manager/tests/src/Kernel/ExcludedPathsTest.php
new file mode 100644
index 000000000000..b9a8eadbe36a
--- /dev/null
+++ b/core/modules/package_manager/tests/src/Kernel/ExcludedPathsTest.php
@@ -0,0 +1,227 @@
+<?php
+
+namespace Drupal\Tests\package_manager\Kernel;
+
+use Drupal\Core\Database\Connection;
+use Drupal\Core\DependencyInjection\ContainerBuilder;
+use Drupal\package_manager\EventSubscriber\ExcludedPathsSubscriber;
+use Drupal\package_manager\PathLocator;
+use org\bovigo\vfs\vfsStream;
+
+/**
+ * @covers \Drupal\package_manager\EventSubscriber\ExcludedPathsSubscriber
+ *
+ * @group package_manager
+ */
+class ExcludedPathsTest extends PackageManagerKernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+
+    // Ensure that any staging directories created by TestStage are created
+    // in the virtual file system.
+    TestStage::$stagingRoot = $this->vfsRoot->url();
+
+    // We need to rebuild the container after setting a private file path, since
+    // the private stream wrapper is only registered if this setting is set.
+    // @see \Drupal\Core\CoreServiceProvider::register()
+    $this->setSetting('file_private_path', 'private');
+    $kernel = $this->container->get('kernel');
+    $kernel->rebuildContainer();
+    $this->container = $kernel->getContainer();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function register(ContainerBuilder $container) {
+    // Normally, package_manager_bypass will disable all the actual staging
+    // operations. In this case, we want to perform them so that we can be sure
+    // that files are staged as expected.
+    $this->setSetting('package_manager_bypass_stager', FALSE);
+
+    $container->getDefinition('package_manager.excluded_paths_subscriber')
+      ->setClass(TestExcludedPathsSubscriber::class);
+
+    parent::register($container);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function disableValidators(ContainerBuilder $container): void {
+    parent::disableValidators($container);
+
+    // Disable the disk space validator, since it tries to inspect the file
+    // system in ways that vfsStream doesn't support, like calling stat() and
+    // disk_free_space().
+    $container->removeDefinition('package_manager.validator.disk_space');
+
+    // Disable the lock file and Composer settings validators, since in this
+    // test we have an imaginary file system without any Composer files.
+    $container->removeDefinition('package_manager.validator.lock_file');
+  }
+
+  /**
+   * Tests that certain paths are excluded from staging operations.
+   */
+  public function testExcludedPaths(): void {
+    $site = [
+      'composer.json' => '{}',
+      'private' => [
+        'ignore.txt' => 'This file should never be staged.',
+      ],
+      'sites' => [
+        'default' => [
+          'services.yml' => <<<END
+# This file should never be staged.
+must_not_be: 'empty'
+END,
+          'settings.local.php' => <<<END
+<?php
+
+/**
+ * @file
+ * This file should never be staged.
+ */
+END,
+          'settings.php' => <<<END
+<?php
+
+/**
+ * @file
+ * This file should never be staged.
+ */
+END,
+          'stage.txt' => 'This file should be staged.',
+        ],
+        'example.com' => [
+          'files' => [
+            'ignore.txt' => 'This file should never be staged.',
+          ],
+          'db.sqlite' => 'This file should never be staged.',
+          'db.sqlite-shm' => 'This file should never be staged.',
+          'db.sqlite-wal' => 'This file should never be staged.',
+          'services.yml' => <<<END
+# This file should never be staged.
+key: "value"
+END,
+          'settings.local.php' => <<<END
+<?php
+
+/**
+ * @file
+ * This file should never be staged.
+ */
+END,
+          'settings.php' => <<<END
+<?php
+
+/**
+ * @file
+ * This file should never be staged.
+ */
+END,
+        ],
+        'simpletest' => [
+          'ignore.txt' => 'This file should never be staged.',
+        ],
+      ],
+      'vendor' => [
+        '.htaccess' => '# This file should never be staged.',
+        'web.config' => 'This file should never be staged.',
+      ],
+    ];
+    vfsStream::create(['active' => $site], $this->vfsRoot);
+
+    $active_dir = $this->vfsRoot->getChild('active')->url();
+
+    $path_locator = $this->prophesize(PathLocator::class);
+    $path_locator->getActiveDirectory()->willReturn($active_dir);
+    $path_locator->getProjectRoot()->willReturn($active_dir);
+    $path_locator->getWebRoot()->willReturn('');
+    $path_locator->getVendorDirectory()->willReturn("$active_dir/vendor");
+    $this->container->set('package_manager.path_locator', $path_locator->reveal());
+
+    $site_path = 'sites/example.com';
+    // Ensure that we are using directories within the fake site fixture for
+    // public and private files.
+    $this->setSetting('file_public_path', "$site_path/files");
+
+    /** @var \Drupal\Tests\package_manager\Kernel\TestExcludedPathsSubscriber $subscriber */
+    $subscriber = $this->container->get('package_manager.excluded_paths_subscriber');
+    $subscriber->sitePath = $site_path;
+
+    // Mock a SQLite database connection to a file in the active directory. The
+    // file should not be staged.
+    $database = $this->prophesize(Connection::class);
+    $database->driver()->willReturn('sqlite');
+    $database->getConnectionOptions()->willReturn([
+      'database' => $site_path . '/db.sqlite',
+    ]);
+    $subscriber->database = $database->reveal();
+
+    $stage = $this->createStage();
+    $stage->create();
+    $stage_dir = $stage->getStageDirectory();
+
+    $ignore = [
+      'sites/simpletest',
+      'vendor/.htaccess',
+      'vendor/web.config',
+      "$site_path/files/ignore.txt",
+      'private/ignore.txt',
+      "$site_path/settings.php",
+      "$site_path/settings.local.php",
+      "$site_path/services.yml",
+      // SQLite databases and their support files should always be ignored.
+      "$site_path/db.sqlite",
+      "$site_path/db.sqlite-shm",
+      "$site_path/db.sqlite-wal",
+      // Default site-specific settings files should be ignored.
+      'sites/default/settings.php',
+      'sites/default/settings.local.php',
+      'sites/default/services.yml',
+    ];
+    foreach ($ignore as $path) {
+      $this->assertFileExists("$active_dir/$path");
+      $this->assertFileDoesNotExist("$stage_dir/$path");
+    }
+    // A non-excluded file in the default site directory should be staged.
+    $this->assertFileExists("$stage_dir/sites/default/stage.txt");
+
+    // A new file added to the staging area in an excluded directory, should not
+    // be copied to the active directory.
+    $file = "$stage_dir/sites/default/no-copy.txt";
+    touch($file);
+    $this->assertFileExists($file);
+    $stage->apply();
+    $this->assertFileDoesNotExist("$active_dir/sites/default/no-copy.txt");
+
+    // The ignored files should still be in the active directory.
+    foreach ($ignore as $path) {
+      $this->assertFileExists("$active_dir/$path");
+    }
+  }
+
+}
+
+/**
+ * A test-only implementation of the excluded path event subscriber.
+ */
+class TestExcludedPathsSubscriber extends ExcludedPathsSubscriber {
+
+  /**
+   * {@inheritdoc}
+   */
+  public $sitePath;
+
+  /**
+   * {@inheritdoc}
+   */
+  public $database;
+
+}
diff --git a/core/modules/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php b/core/modules/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php
index 414d35124229..e6e7b7504c06 100644
--- a/core/modules/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php
+++ b/core/modules/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php
@@ -25,6 +25,14 @@ abstract class PackageManagerKernelTestBase extends KernelTestBase {
     'package_manager_bypass',
   ];
 
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+    $this->installConfig('package_manager');
+  }
+
   /**
    * {@inheritdoc}
    */
@@ -118,6 +126,20 @@ protected function registerPostUpdateFunctions(): void {
  */
 class TestStage extends Stage {
 
+  /**
+   * The directory where staging areas will be created.
+   *
+   * @var string
+   */
+  public static $stagingRoot;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static function getStagingRoot(): string {
+    return static::$stagingRoot ?: parent::getStagingRoot();
+  }
+
   /**
    * {@inheritdoc}
    */
diff --git a/core/modules/package_manager/tests/src/Kernel/WritableFileSystemValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/WritableFileSystemValidatorTest.php
index d6e504ea138c..21cf1b200774 100644
--- a/core/modules/package_manager/tests/src/Kernel/WritableFileSystemValidatorTest.php
+++ b/core/modules/package_manager/tests/src/Kernel/WritableFileSystemValidatorTest.php
@@ -113,6 +113,7 @@ public function testWritable(int $root_permissions, int $vendor_permissions, arr
 
     $path_locator = $this->prophesize(PathLocator::class);
     $path_locator->getActiveDirectory()->willReturn($root->url());
+    $path_locator->getProjectRoot()->willReturn($root->url());
     $path_locator->getWebRoot()->willReturn('');
     $path_locator->getVendorDirectory()->willReturn($vendor->url());
     $this->container->set('package_manager.path_locator', $path_locator->reveal());
-- 
GitLab