From 8524be362e3ff22e20a79645f89ecf00324e8c13 Mon Sep 17 00:00:00 2001
From: Alex Pott <alex.a.pott@googlemail.com>
Date: Fri, 4 Oct 2019 23:05:43 +0100
Subject: [PATCH] Issue #3031379 by heddn, Mile23, greg.1.anderson, Charlie ChX
 Negyesi, alexpott, Mixologic, jibran, catch, Lendude: Add a new test type to
 do real update testing

---
 composer.json                                 |   5 +-
 composer.lock                                 |  87 ++-
 core/drupalci.yml                             |   5 +
 core/lib/Drupal/Core/Test/TestDiscovery.php   |   2 +
 core/phpunit.xml.dist                         |   3 +
 core/scripts/run-tests.sh                     |   1 +
 .../BuildTests/Framework/BuildTestBase.php    | 599 ++++++++++++++++++
 .../BuildTests/Framework/DrupalMinkClient.php |  74 +++
 .../ExternalCommandRequirementsTrait.php      | 106 ++++
 .../Framework/Tests/BuildTestTest.php         | 134 ++++
 .../Framework/Tests/DrupalMinkClientTest.php  | 101 +++
 .../Tests/ExternalCommandRequirementTest.php  | 182 ++++++
 core/tests/TestSuites/BuildTestSuite.php      |  27 +
 core/tests/bootstrap.php                      |   1 +
 14 files changed, 1312 insertions(+), 15 deletions(-)
 create mode 100644 core/tests/Drupal/BuildTests/Framework/BuildTestBase.php
 create mode 100644 core/tests/Drupal/BuildTests/Framework/DrupalMinkClient.php
 create mode 100644 core/tests/Drupal/BuildTests/Framework/ExternalCommandRequirementsTrait.php
 create mode 100644 core/tests/Drupal/BuildTests/Framework/Tests/BuildTestTest.php
 create mode 100644 core/tests/Drupal/BuildTests/Framework/Tests/DrupalMinkClientTest.php
 create mode 100644 core/tests/Drupal/BuildTests/Framework/Tests/ExternalCommandRequirementTest.php
 create mode 100644 core/tests/TestSuites/BuildTestSuite.php

diff --git a/composer.json b/composer.json
index 2a4ba65b33f1..d04cc56ba0b7 100644
--- a/composer.json
+++ b/composer.json
@@ -22,7 +22,10 @@
         "symfony/css-selector": "^3.4.0",
         "symfony/phpunit-bridge": "^3.4.3",
         "symfony/debug": "^3.4.0",
-        "justinrainbow/json-schema": "^5.2"
+        "justinrainbow/json-schema": "^5.2",
+        "symfony/filesystem": "~3.4.0",
+        "symfony/finder": "~3.4.0",
+        "symfony/lock": "~3.4.0"
     },
     "minimum-stability": "dev",
     "prefer-stable": true,
diff --git a/composer.lock b/composer.lock
index eeabdab4591e..2fd89e373a0e 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
         "This file is @generated automatically"
     ],
-    "content-hash": "38c8384dea91c895efa0d3119c037f84",
+    "content-hash": "7c19b29738cf44de507d0baa3bd31665",
     "packages": [
         {
             "name": "asm89/stack-cors",
@@ -3652,9 +3652,6 @@
                 "ext-zip": "Enabling the zip extension allows you to unzip archives",
                 "ext-zlib": "Allow gzip compression of HTTP requests"
             },
-            "bin": [
-                "bin/composer"
-            ],
             "type": "library",
             "extra": {
                 "branch-alias": {
@@ -5793,16 +5790,16 @@
         },
         {
             "name": "symfony/filesystem",
-            "version": "v3.4.28",
+            "version": "v3.4.31",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/filesystem.git",
-                "reference": "acf99758b1df8e9295e6b85aa69f294565c9fedb"
+                "reference": "00e3a6ddd723b8bcfe4f2a1b6f82b98eeeb51516"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/filesystem/zipball/acf99758b1df8e9295e6b85aa69f294565c9fedb",
-                "reference": "acf99758b1df8e9295e6b85aa69f294565c9fedb",
+                "url": "https://api.github.com/repos/symfony/filesystem/zipball/00e3a6ddd723b8bcfe4f2a1b6f82b98eeeb51516",
+                "reference": "00e3a6ddd723b8bcfe4f2a1b6f82b98eeeb51516",
                 "shasum": ""
             },
             "require": {
@@ -5839,20 +5836,20 @@
             ],
             "description": "Symfony Filesystem Component",
             "homepage": "https://symfony.com",
-            "time": "2019-02-04T21:34:32+00:00"
+            "time": "2019-08-20T13:31:17+00:00"
         },
         {
             "name": "symfony/finder",
-            "version": "v3.4.28",
+            "version": "v3.4.31",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/finder.git",
-                "reference": "fa5d962a71f2169dfe1cbae217fa5a2799859f6c"
+                "reference": "1fcad80b440abcd1451767349906b6f9d3961d37"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/finder/zipball/fa5d962a71f2169dfe1cbae217fa5a2799859f6c",
-                "reference": "fa5d962a71f2169dfe1cbae217fa5a2799859f6c",
+                "url": "https://api.github.com/repos/symfony/finder/zipball/1fcad80b440abcd1451767349906b6f9d3961d37",
+                "reference": "1fcad80b440abcd1451767349906b6f9d3961d37",
                 "shasum": ""
             },
             "require": {
@@ -5888,7 +5885,69 @@
             ],
             "description": "Symfony Finder Component",
             "homepage": "https://symfony.com",
-            "time": "2019-05-24T12:25:55+00:00"
+            "time": "2019-08-14T09:39:58+00:00"
+        },
+        {
+            "name": "symfony/lock",
+            "version": "v3.4.31",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/lock.git",
+                "reference": "e434629a79538238cce74ef62fa870b149e1b3b8"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/lock/zipball/e434629a79538238cce74ef62fa870b149e1b3b8",
+                "reference": "e434629a79538238cce74ef62fa870b149e1b3b8",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^5.5.9|>=7.0.8",
+                "psr/log": "~1.0",
+                "symfony/polyfill-php70": "~1.0"
+            },
+            "require-dev": {
+                "predis/predis": "~1.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "3.4-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Component\\Lock\\": ""
+                },
+                "exclude-from-classmap": [
+                    "/Tests/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Jérémy Derussé",
+                    "email": "jeremy@derusse.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Symfony Lock Component",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "cas",
+                "flock",
+                "locking",
+                "mutex",
+                "redlock",
+                "semaphore"
+            ],
+            "time": "2019-08-14T11:59:53+00:00"
         },
         {
             "name": "symfony/phpunit-bridge",
diff --git a/core/drupalci.yml b/core/drupalci.yml
index 6ce07ee7ad11..d22564106352 100644
--- a/core/drupalci.yml
+++ b/core/drupalci.yml
@@ -34,6 +34,11 @@ build:
          testgroups: '--all'
          suppress-deprecations: false
          halt-on-fail: false
+      run_tests.build:
+        types: 'PHPUnit-Build'
+        testgroups: '--all'
+        suppress-deprecations: false
+        halt-on-fail: false
       run_tests.functional:
         types: 'PHPUnit-Functional'
         testgroups: '--all'
diff --git a/core/lib/Drupal/Core/Test/TestDiscovery.php b/core/lib/Drupal/Core/Test/TestDiscovery.php
index 7bb08ac4a316..552a72acf9e9 100644
--- a/core/lib/Drupal/Core/Test/TestDiscovery.php
+++ b/core/lib/Drupal/Core/Test/TestDiscovery.php
@@ -81,6 +81,7 @@ public function registerTestNamespaces() {
 
     // Add PHPUnit test namespaces of Drupal core.
     $this->testNamespaces['Drupal\\Tests\\'] = [$this->root . '/core/tests/Drupal/Tests'];
+    $this->testNamespaces['Drupal\\BuildTests\\'] = [$this->root . '/core/tests/Drupal/BuildTests'];
     $this->testNamespaces['Drupal\\KernelTests\\'] = [$this->root . '/core/tests/Drupal/KernelTests'];
     $this->testNamespaces['Drupal\\FunctionalTests\\'] = [$this->root . '/core/tests/Drupal/FunctionalTests'];
     $this->testNamespaces['Drupal\\FunctionalJavascriptTests\\'] = [$this->root . '/core/tests/Drupal/FunctionalJavascriptTests'];
@@ -102,6 +103,7 @@ public function registerTestNamespaces() {
       $this->testNamespaces["Drupal\\Tests\\$name\\Unit\\"][] = "$base_path/tests/src/Unit";
       $this->testNamespaces["Drupal\\Tests\\$name\\Kernel\\"][] = "$base_path/tests/src/Kernel";
       $this->testNamespaces["Drupal\\Tests\\$name\\Functional\\"][] = "$base_path/tests/src/Functional";
+      $this->testNamespaces["Drupal\\Tests\\$name\\Build\\"][] = "$base_path/tests/src/Build";
       $this->testNamespaces["Drupal\\Tests\\$name\\FunctionalJavascript\\"][] = "$base_path/tests/src/FunctionalJavascript";
 
       // Add discovery for traits which are shared between different test
diff --git a/core/phpunit.xml.dist b/core/phpunit.xml.dist
index c24d4008d00e..eb6354181209 100644
--- a/core/phpunit.xml.dist
+++ b/core/phpunit.xml.dist
@@ -45,6 +45,9 @@
     <testsuite name="functional-javascript">
       <file>./tests/TestSuites/FunctionalJavascriptTestSuite.php</file>
     </testsuite>
+    <testsuite name="build">
+      <file>./tests/TestSuites/BuildTestSuite.php</file>
+    </testsuite>
   </testsuites>
   <listeners>
     <listener class="\Drupal\Tests\Listeners\DrupalListener">
diff --git a/core/scripts/run-tests.sh b/core/scripts/run-tests.sh
index 45fcd3bac3c6..39ae5ff69f2c 100755
--- a/core/scripts/run-tests.sh
+++ b/core/scripts/run-tests.sh
@@ -18,6 +18,7 @@
 use Drupal\Core\Test\TestDiscovery;
 use PHPUnit\Framework\TestCase;
 use Symfony\Component\Console\Output\ConsoleOutput;
+use Symfony\Component\Filesystem\Filesystem as SymfonyFilesystem;
 use Symfony\Component\HttpFoundation\Request;
 
 // Define some colors for display.
diff --git a/core/tests/Drupal/BuildTests/Framework/BuildTestBase.php b/core/tests/Drupal/BuildTests/Framework/BuildTestBase.php
new file mode 100644
index 000000000000..f698998f87d8
--- /dev/null
+++ b/core/tests/Drupal/BuildTests/Framework/BuildTestBase.php
@@ -0,0 +1,599 @@
+<?php
+
+namespace Drupal\BuildTests\Framework;
+
+use Behat\Mink\Driver\Goutte\Client;
+use Behat\Mink\Driver\GoutteDriver;
+use Behat\Mink\Mink;
+use Behat\Mink\Session;
+use Drupal\Component\FileSystem\FileSystem as DrupalFilesystem;
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\BrowserKit\Client as SymfonyClient;
+use Symfony\Component\Filesystem\Filesystem as SymfonyFilesystem;
+use Symfony\Component\Finder\Finder;
+use Symfony\Component\Lock\Factory;
+use Symfony\Component\Lock\Store\FlockStore;
+use Symfony\Component\Process\PhpExecutableFinder;
+use Symfony\Component\Process\Process;
+
+/**
+ * Provides a workspace to test build processes.
+ *
+ * If you need to build a file system and then run a command from the command
+ * line then this is the test framework for you.
+ *
+ * Tests using this interface run in separate processes.
+ *
+ * Tests can perform HTTP requests against the assembled codebase.
+ *
+ * The results of these HTTP requests can be asserted using Mink.
+ *
+ * This framework does not use the same Mink extensions as BrowserTestBase.
+ *
+ * Features:
+ * - Provide complete isolation between the test runner and the site under test.
+ * - Provide a workspace where filesystem build processes can be performed.
+ * - Allow for the use of PHP's build-in HTTP server to send requests to the
+ *   site built using the filesystem.
+ * - Allow for commands and HTTP requests to be made to different subdirectories
+ *   of the workspace filesystem, to facilitate comparison between different
+ *   build results, and to support Composer builds which have an alternate
+ *   docroot.
+ * - Provide as little framework as possible. Convenience methods should be
+ *   built into the test, or abstract base classes.
+ * - Allow parallel testing, using random/unique port numbers for different HTTP
+ *   servers.
+ * - Allow the use of PHPUnit-style (at)require annotations for external shell
+ *   commands.
+ *
+ * We don't use UiHelperInterface because it is too tightly integrated to
+ * Drupal.
+ */
+abstract class BuildTestBase extends TestCase {
+
+  use ExternalCommandRequirementsTrait;
+
+  /**
+   * The working directory where this test will manipulate files.
+   *
+   * Use getWorkspaceDirectory() to access this information.
+   *
+   * @var string
+   *
+   * @see self::getWorkspaceDirectory()
+   */
+  private $workspaceDir;
+
+  /**
+   * The process that's running the HTTP server.
+   *
+   * @var \Symfony\Component\Process\Process
+   *
+   * @see self::standUpServer()
+   * @see self::stopServer()
+   */
+  private $serverProcess = NULL;
+
+  /**
+   * Default to destroying build artifacts after a test finishes.
+   *
+   * Mainly useful for debugging.
+   *
+   * @var bool
+   */
+  protected $destroyBuild = TRUE;
+
+  /**
+   * The docroot for the server process.
+   *
+   * This stores the last docroot directory used to start the server process. We
+   * keep this information so we can restart the server if the desired docroot
+   * changes.
+   *
+   * @var string
+   */
+  private $serverDocroot = NULL;
+
+  /**
+   * Our native host name, used by PHP when it starts up the server.
+   *
+   * Requests should always be made to 'localhost', and not this IP address.
+   *
+   * @var string
+   */
+  private static $hostName = '127.0.0.1';
+
+  /**
+   * Port that will be tested.
+   *
+   * Generated internally. Use getPortNumber().
+   *
+   * @var int
+   */
+  private $hostPort;
+
+  /**
+   * The Mink session manager.
+   *
+   * @var \Behat\Mink\Mink
+   */
+  private $mink;
+
+  /**
+   * @var \Symfony\Component\Lock\Factory
+   */
+  private $lockFactory;
+
+  /**
+   * @var \Symfony\Component\Lock\Lock[]
+   */
+  private $locks;
+
+  /**
+   * The most recent command process.
+   *
+   * @var \Symfony\Component\Process\Process
+   *
+   * @see ::executeCommand()
+   */
+  private $commandProcess;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function setUpBeforeClass() {
+    parent::setUpBeforeClass();
+    static::checkClassCommandRequirements();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+    static::checkMethodCommandRequirements($this->getName());
+    $this->phpFinder = new PhpExecutableFinder();
+    // Set up the workspace directory.
+    // @todo Glean working directory from env vars, etc.
+    $fs = new SymfonyFilesystem();
+    $this->workspaceDir = $fs->tempnam(DrupalFilesystem::getOsTemporaryDirectory(), '/build_workspace_' . md5($this->getName() . microtime(TRUE)));
+    $fs->remove($this->workspaceDir);
+    $fs->mkdir($this->workspaceDir);
+    $this->initMink();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function tearDown() {
+    parent::tearDown();
+
+    $this->stopServer();
+    $ws = $this->getWorkspaceDirectory();
+    $fs = new SymfonyFilesystem();
+    if ($this->destroyBuild && $fs->exists($ws)) {
+      // Filter out symlinks as chmod cannot alter them.
+      $finder = new Finder();
+      $finder->in($ws)
+        ->directories()
+        ->ignoreVCS(FALSE)
+        ->ignoreDotFiles(FALSE)
+        // composer script is a symlink and fails chmod. Ignore it.
+        ->notPath('/^vendor\/bin\/composer$/');
+      $fs->chmod($finder->getIterator(), 0775, 0000);
+      $fs->remove($ws);
+    }
+  }
+
+  /**
+   * Get the working directory within the workspace, creating if necessary.
+   *
+   * @param string $working_dir
+   *   The path within the workspace directory.
+   *
+   * @return string
+   *   The full path to the working directory within the workspace directory.
+   */
+  protected function getWorkingPath($working_dir = NULL) {
+    $full_path = $this->getWorkspaceDirectory();
+    if ($working_dir) {
+      $full_path .= '/' . $working_dir;
+    }
+    if (!file_exists($full_path)) {
+      $fs = new SymfonyFilesystem();
+      $fs->mkdir($full_path);
+    }
+    return $full_path;
+  }
+
+  /**
+   * Set up the Mink session manager.
+   *
+   * @return \Behat\Mink\Session
+   */
+  protected function initMink() {
+    // If the Symfony BrowserKit client can followMetaRefresh(), we should use
+    // the Goutte descendent instead of ours.
+    if (method_exists(SymfonyClient::class, 'followMetaRefresh')) {
+      $client = new Client();
+    }
+    else {
+      $client = new DrupalMinkClient();
+    }
+    $client->followMetaRefresh(TRUE);
+    $driver = new GoutteDriver($client);
+    $session = new Session($driver);
+    $this->mink = new Mink();
+    $this->mink->registerSession('default', $session);
+    $this->mink->setDefaultSessionName('default');
+    $session->start();
+    return $session;
+  }
+
+  /**
+   * Get the Mink instance.
+   *
+   * Use the Mink object to perform assertions against the content returned by a
+   * request.
+   *
+   * @return \Behat\Mink\Mink
+   *   The Mink object.
+   */
+  public function getMink() {
+    return $this->mink;
+  }
+
+  /**
+   * Full path to the workspace where this test can build.
+   *
+   * This is often a directory within the system's temporary directory.
+   *
+   * @return string
+   *   Full path to the workspace where this test can build.
+   */
+  public function getWorkspaceDirectory() {
+    return $this->workspaceDir;
+  }
+
+  /**
+   * Assert that text is present in the error output of the most recent command.
+   *
+   * @param string $expected
+   *   Text we expect to find in the error output of the command.
+   */
+  public function assertErrorOutputContains($expected) {
+    $this->assertContains($expected, $this->commandProcess->getErrorOutput());
+  }
+
+  /**
+   * Assert that text is present in the output of the most recent command.
+   *
+   * @param string $expected
+   *   Text we expect to find in the output of the command.
+   */
+  public function assertCommandOutputContains($expected) {
+    $this->assertContains($expected, $this->commandProcess->getOutput());
+  }
+
+  /**
+   * Asserts that the last command ran without error.
+   *
+   * This assertion checks whether the last command returned an exit code of 0.
+   *
+   * If you need to assert a different exit code, then you can use
+   * executeCommand() and perform a different assertion on the process object.
+   */
+  public function assertCommandSuccessful() {
+    return $this->assertCommandExitCode(0);
+  }
+
+  /**
+   * Asserts that the last command returned the specified exit code.
+   *
+   * @param int $expected_code
+   *   The expected process exit code.
+   */
+  public function assertCommandExitCode($expected_code) {
+    $this->assertEquals($expected_code, $this->commandProcess->getExitCode(),
+      'COMMAND: ' . $this->commandProcess->getCommandLine() . "\n" .
+      'OUTPUT: ' . $this->commandProcess->getOutput() . "\n" .
+      'ERROR: ' . $this->commandProcess->getErrorOutput() . "\n"
+    );
+  }
+
+  /**
+   * Run a command.
+   *
+   * @param string $command_line
+   *   A command line to run in an isolated process.
+   * @param string $working_dir
+   *   (optional) A working directory relative to the workspace, within which to
+   *   execute the command. Defaults to the workspace directory.
+   *
+   * @return \Symfony\Component\Process\Process
+   */
+  public function executeCommand($command_line, $working_dir = NULL) {
+    $this->commandProcess = new Process($command_line);
+    $this->commandProcess->setWorkingDirectory($this->getWorkingPath($working_dir))
+      ->setTimeout(300)
+      ->setIdleTimeout(300);
+    $this->commandProcess->run();
+    return $this->commandProcess;
+  }
+
+  /**
+   * Helper function to assert that the last visit was a Drupal site.
+   *
+   * This method asserts that the X-Generator header shows that the site is a
+   * Drupal site.
+   */
+  public function assertDrupalVisit() {
+    $this->getMink()->assertSession()->responseHeaderMatches('X-Generator', '/Drupal \d+ \(https:\/\/www.drupal.org\)/');
+  }
+
+  /**
+   * Visit a URI on the HTTP server.
+   *
+   * The concept here is that there could be multiple potential docroots in the
+   * workspace, so you can use whichever ones you want.
+   *
+   * @param string $request_uri
+   *   (optional) The non-host part of the URL. Example: /some/path?foo=bar.
+   *   Defaults to visiting the homepage.
+   * @param string $working_dir
+   *   (optional) Relative path within the test workspace file system that will
+   *   be the docroot for the request. Defaults to the workspace directory.
+   *
+   * @return \Behat\Mink\Mink
+   *   The Mink object. Perform assertions against this.
+   *
+   * @throws \InvalidArgumentException
+   *   Thrown when $request_uri does not start with a slash.
+   */
+  public function visit($request_uri = '/', $working_dir = NULL) {
+    if ($request_uri[0] !== '/') {
+      throw new \InvalidArgumentException('URI: ' . $request_uri . ' must be relative. Example: /some/path?foo=bar');
+    }
+    // Try to make a server.
+    $this->standUpServer($working_dir);
+
+    $request = 'http://localhost:' . $this->getPortNumber() . $request_uri;
+    $this->mink->getSession()->visit($request);
+    return $this->mink;
+  }
+
+  /**
+   * Makes a local test server using PHP's internal HTTP server.
+   *
+   * Test authors should call visit() or assertVisit() instead.
+   *
+   * @param string|null $working_dir
+   *   (optional) Server docroot relative to the workspace file system. Defaults
+   *   to the workspace directory.
+   */
+  protected function standUpServer($working_dir = NULL) {
+    // If the user wants to test a new docroot, we have to shut down the old
+    // server process and generate a new port number.
+    if ($working_dir !== $this->serverDocroot && !empty($this->serverProcess)) {
+      $this->stopServer();
+    }
+    // If there's not a server at this point, make one.
+    $this->serverProcess = $this->instantiateServer($this->getPortNumber(), $working_dir);
+    if ($this->serverProcess) {
+      $this->serverDocroot = $working_dir;
+    }
+  }
+
+  /**
+   * Do the work of making a server process.
+   *
+   * Test authors should call visit() or assertVisit() instead.
+   *
+   * @param int $port
+   *   The port number for the server.
+   * @param string|null $working_dir
+   *   (optional) Server docroot relative to the workspace filesystem. Defaults
+   *   to the workspace directory.
+   *
+   * @return \Symfony\Component\Process\Process
+   *   The server process.
+   *
+   * @throws \RuntimeException
+   *   Thrown if we were unable to start a web server.
+   */
+  protected function instantiateServer($port, $working_dir = NULL) {
+    $finder = new PhpExecutableFinder();
+    $working_path = $this->getWorkingPath($working_dir);
+    $server = [
+      $finder->find(),
+      '-S',
+      self::$hostName . ':' . $port,
+      '-t',
+      $working_path,
+    ];
+    $ps = new Process($server, $working_path);
+    $ps->setIdleTimeout(30)
+      ->setTimeout(30)
+      ->start();
+    // Wait until the web server has started. It is started if the port is no
+    // longer available.
+    for ($i = 0; $i < 1000; $i++) {
+      if (!$this->checkPortIsAvailable($port)) {
+        return $ps;
+      }
+      usleep(1000);
+    }
+    throw new \RuntimeException('Unable to start the web server.');
+  }
+
+  /**
+   * Stop the HTTP server, zero out all necessary variables.
+   */
+  protected function stopServer() {
+    if (!empty($this->serverProcess)) {
+      $this->serverProcess->stop();
+    }
+    $this->serverProcess = NULL;
+    $this->serverDocroot = NULL;
+    $this->hostPort = NULL;
+    $this->initMink();
+  }
+
+  /**
+   * Discover an available port number.
+   *
+   * @return int
+   *   The available port number that we discovered.
+   *
+   * @throws \RuntimeException
+   *   Thrown when there are no available ports within the range.
+   */
+  protected function findAvailablePort() {
+    $counter = 100;
+    while ($counter--) {
+      $port = random_int(1024, 65535);
+      if ($this->checkPortIsAvailable($port)) {
+        return $port;
+      }
+    }
+    throw new \RuntimeException('Unable to find a port available to run the web server.');
+  }
+
+  /**
+   * Checks whether a port is available.
+   *
+   * @param $port
+   *   A number between 1024 and 65536.
+   *
+   * @return bool
+   */
+  protected function checkPortIsAvailable($port) {
+    if ($this->lockAcquired($port)) {
+      $fp = @fsockopen(self::$hostName, $port, $errno, $errstr, 1);
+      // If fsockopen() fails to connect, probably nothing is listening.
+      // It could be a firewall but that's impossible to detect, so as a
+      // best guess let's return it as available.
+      if ($fp === FALSE) {
+        return TRUE;
+      }
+      else {
+        $this->lockRelease($port);
+        fclose($fp);
+      }
+    }
+    return FALSE;
+  }
+
+  /**
+   * Get the port number for requests.
+   *
+   * Test should never call this. Used by standUpServer().
+   *
+   * @return int
+   */
+  protected function getPortNumber() {
+    if (empty($this->hostPort)) {
+      $this->hostPort = $this->findAvailablePort();
+    }
+    return $this->hostPort;
+  }
+
+  /**
+   * Get a lock.
+   *
+   * @param $name
+   *   The name of the lock.
+   *
+   * @return bool
+   *   TRUE if the lock has been successfully acquired.
+   */
+  protected function lockAcquired($name) {
+    if (!$this->lockFactory) {
+      $store = new FlockStore(DrupalFilesystem::getOsTemporaryDirectory());
+      $this->lockFactory = new Factory($store);
+    }
+    $name = $this->getLockName($name);
+    if (!isset($this->locks[$name])) {
+      $this->locks[$name] = $this->lockFactory->createLock($name);
+    }
+    $lock = $this->locks[$name];
+    return $lock->isAcquired() || $lock->acquire();
+  }
+
+  /**
+   * Releases a lock.
+   *
+   * @param $name
+   *   The name of the lock.
+   */
+  protected function lockRelease($name) {
+    $name = $this->getLockName($name);
+    if (isset($this->lock[$name])) {
+      $this->locks[$name]->release();
+    }
+  }
+
+  /**
+   * Gets the lock name.
+   *
+   * @param $name
+   *   The lock name.
+   *
+   * @return string
+   *   Prefix lock name.
+   */
+  protected function getLockName($name) {
+    return 'drupal-build-test-' . $name;
+  }
+
+  /**
+   * Copy the current working codebase into a workspace.
+   *
+   * Use this method to copy the current codebase, including any patched
+   * changes, into the workspace.
+   *
+   * By default, the copy will exclude sites/default/settings.php,
+   * sites/default/files, and vendor/. Use the $iterator parameter to override
+   * this behavior.
+   *
+   * @param \Iterator|null $iterator
+   *   (optional) An iterator of all the files to copy. Default behavior is to
+   *   exclude site-specific directories and files.
+   * @param string|null $working_dir
+   *   (optional) Relative path within the test workspace file system that will
+   *   contain the copy of the codebase. Defaults to the workspace directory.
+   */
+  public function copyCodebase(\Iterator $iterator = NULL, $working_dir = NULL) {
+    $working_path = $this->getWorkingPath($working_dir);
+
+    if ($iterator === NULL) {
+      $finder = new Finder();
+      $finder->files()
+        ->in($this->getDrupalRoot())
+        ->exclude([
+          'sites/default/files',
+          'sites/simpletest',
+          'vendor',
+        ])
+        ->notPath('/sites\/default\/settings\..*php/')
+        ->ignoreDotFiles(FALSE)
+        ->ignoreVCS(FALSE);
+      $iterator = $finder->getIterator();
+    }
+
+    $fs = new SymfonyFilesystem();
+    $options = ['override' => TRUE, 'delete' => FALSE];
+    $fs->mirror($this->getDrupalRoot(), $working_path, $iterator, $options);
+  }
+
+  /**
+   * Get the root path of this Drupal codebase.
+   *
+   * @return string
+   *   The full path to the root of this Drupal codebase.
+   */
+  protected function getDrupalRoot() {
+    return realpath(dirname(__DIR__, 5));
+  }
+
+}
diff --git a/core/tests/Drupal/BuildTests/Framework/DrupalMinkClient.php b/core/tests/Drupal/BuildTests/Framework/DrupalMinkClient.php
new file mode 100644
index 000000000000..ad61087976fb
--- /dev/null
+++ b/core/tests/Drupal/BuildTests/Framework/DrupalMinkClient.php
@@ -0,0 +1,74 @@
+<?php
+
+namespace Drupal\BuildTests\Framework;
+
+use Behat\Mink\Driver\Goutte\Client;
+use Symfony\Component\BrowserKit\Client as SymfonyClient;
+
+/**
+ * Extend the Mink client for Drupal use-cases.
+ *
+ * This is adapted from https://github.com/symfony/symfony/pull/27118.
+ *
+ * @todo Update this client when Drupal starts using Symfony 4.2.0+.
+ *       https://www.drupal.org/project/drupal/issues/3077785
+ */
+class DrupalMinkClient extends Client {
+
+  /**
+   * Whether to follow meta redirects or not.
+   *
+   * @var bool
+   *
+   * @see \Drupal\BuildTests\Framework\DrupalMinkClient::followMetaRefresh()
+   */
+  protected $followMetaRefresh;
+
+  /**
+   * Sets whether to automatically follow meta refresh redirects or not.
+   *
+   * @param bool $followMetaRefresh
+   *   (optional) Whether to follow meta redirects. Defaults to TRUE.
+   */
+  public function followMetaRefresh($followMetaRefresh = TRUE) {
+    $this->followMetaRefresh = $followMetaRefresh;
+  }
+
+  /**
+   * Glean the meta refresh URL from the current page content.
+   *
+   * @return string|null
+   *   Either the redirect URL that was found, or NULL if none was found.
+   */
+  private function getMetaRefreshUrl() {
+    $metaRefresh = $this->getCrawler()->filter('meta[http-equiv="Refresh"], meta[http-equiv="refresh"]');
+    foreach ($metaRefresh->extract(['content']) as $content) {
+      if (preg_match('/^\s*0\s*;\s*URL\s*=\s*(?|\'([^\']++)|"([^"]++)|([^\'"].*))/i', $content, $m)) {
+        return str_replace("\t\r\n", '', rtrim($m[1]));
+      }
+    }
+    return NULL;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function request($method, $uri, array $parameters = [], array $files = [], array $server = [], $content = NULL, $changeHistory = TRUE) {
+    $this->crawler = parent::request($method, $uri, $parameters, $files, $server, $content, $changeHistory);
+    // Check for meta refresh redirect and follow it.
+    if ($this->followMetaRefresh && NULL !== $redirect = $this->getMetaRefreshUrl()) {
+      $this->redirect = $redirect;
+      // $this->redirects is private on the BrowserKit client, so we have to use
+      // reflection to manage the redirects stack.
+      $ref_redirects = new \ReflectionProperty(SymfonyClient::class, 'redirects');
+      $ref_redirects->setAccessible(TRUE);
+      $redirects = $ref_redirects->getValue($this);
+      $redirects[serialize($this->history->current())] = TRUE;
+      $ref_redirects->setValue($this, $redirects);
+
+      $this->crawler = $this->followRedirect();
+    }
+    return $this->crawler;
+  }
+
+}
diff --git a/core/tests/Drupal/BuildTests/Framework/ExternalCommandRequirementsTrait.php b/core/tests/Drupal/BuildTests/Framework/ExternalCommandRequirementsTrait.php
new file mode 100644
index 000000000000..2fb4b1497b35
--- /dev/null
+++ b/core/tests/Drupal/BuildTests/Framework/ExternalCommandRequirementsTrait.php
@@ -0,0 +1,106 @@
+<?php
+
+namespace Drupal\BuildTests\Framework;
+
+use PHPUnit\Framework\SkippedTestError;
+use PHPUnit\Util\Test;
+use Symfony\Component\Process\ExecutableFinder;
+
+/**
+ * Allows test classes to require external command line applications.
+ *
+ * Use annotation such as '(at)requires externalCommand git'.
+ */
+trait ExternalCommandRequirementsTrait {
+
+  /**
+   * A list of existing external commands we've already discovered.
+   *
+   * @var string[]
+   */
+  private static $existingCommands = [];
+
+  /**
+   * Checks whether required external commands are available per test class.
+   *
+   * @throws \PHPUnit\Framework\SkippedTestError
+   *   Thrown when the requirements are not met, and this test should be
+   *   skipped. Callers should not catch this exception.
+   */
+  private static function checkClassCommandRequirements() {
+    $annotations = Test::parseTestMethodAnnotations(static::class);
+    if (!empty($annotations['class']['requires'])) {
+      static::checkExternalCommandRequirements($annotations['class']['requires']);
+    }
+  }
+
+  /**
+   * Checks whether required external commands are available per method.
+   *
+   * @throws \PHPUnit\Framework\SkippedTestError
+   *   Thrown when the requirements are not met, and this test should be
+   *   skipped. Callers should not catch this exception.
+   */
+  private static function checkMethodCommandRequirements($name) {
+    $annotations = Test::parseTestMethodAnnotations(static::class, $name);
+    if (!empty($annotations['method']['requires'])) {
+      static::checkExternalCommandRequirements($annotations['method']['requires']);
+    }
+  }
+
+  /**
+   * Checks missing external command requirements.
+   *
+   * @param string[] $annotations
+   *   A list of requires annotations from either a method or class annotation.
+   *
+   * @throws \PHPUnit\Framework\SkippedTestError
+   *   Thrown when the requirements are not met, and this test should be
+   *   skipped. Callers should not catch this exception.
+   */
+  private static function checkExternalCommandRequirements(array $annotations) {
+    // Make a list of required commands.
+    $required_commands = [];
+    foreach ($annotations as $requirement) {
+      if (strpos($requirement, 'externalCommand ') === 0) {
+        $command = trim(str_replace('externalCommand ', '', $requirement));
+        // Use named keys to avoid duplicates.
+        $required_commands[$command] = $command;
+      }
+    }
+
+    // Figure out which commands are not available.
+    $unavailable = [];
+    foreach ($required_commands as $required_command) {
+      if (!in_array($required_command, self::$existingCommands)) {
+        if (static::externalCommandIsAvailable($required_command)) {
+          // Cache existing commands so we don't have to ask again.
+          self::$existingCommands[] = $required_command;
+        }
+        else {
+          $unavailable[] = $required_command;
+        }
+      }
+    }
+
+    // Skip the test if there were some we couldn't find.
+    if (!empty($unavailable)) {
+      throw new SkippedTestError('Required external commands: ' . implode(', ', $unavailable));
+    }
+  }
+
+  /**
+   * Determine if an external command is available.
+   *
+   * @param $command
+   *   The external command.
+   *
+   * @return bool
+   *   TRUE if external command is available, else FALSE.
+   */
+  private static function externalCommandIsAvailable($command) {
+    $finder = new ExecutableFinder();
+    return (bool) $finder->find($command);
+  }
+
+}
diff --git a/core/tests/Drupal/BuildTests/Framework/Tests/BuildTestTest.php b/core/tests/Drupal/BuildTests/Framework/Tests/BuildTestTest.php
new file mode 100644
index 000000000000..61baf4f3c24b
--- /dev/null
+++ b/core/tests/Drupal/BuildTests/Framework/Tests/BuildTestTest.php
@@ -0,0 +1,134 @@
+<?php
+
+namespace Drupal\BuildTests\Framework\Tests;
+
+use Drupal\BuildTests\Framework\BuildTestBase;
+use org\bovigo\vfs\vfsStream;
+use Symfony\Component\Finder\Finder;
+
+/**
+ * @coversDefaultClass \Drupal\BuildTests\Framework\BuildTestBase
+ * @group Build
+ */
+class BuildTestTest extends BuildTestBase {
+
+  /**
+   * Ensure that workspaces work.
+   */
+  public function testWorkspace() {
+    $test_directory = 'test_directory';
+
+    // Execute an empty command through the shell to build out a working
+    // directory.
+    $process = $this->executeCommand('', $test_directory);
+    $this->assertCommandSuccessful();
+
+    // Assert that our working directory exists and is in use by the process.
+    $workspace = $this->getWorkspaceDirectory();
+    $working_path = $workspace . '/' . $test_directory;
+    $this->assertDirectoryExists($working_path);
+    $this->assertEquals($working_path, $process->getWorkingDirectory());
+  }
+
+  /**
+   * @covers ::copyCodebase
+   */
+  public function testCopyCodebase() {
+    $test_directory = 'copied_codebase';
+    $this->copyCodebase(NULL, $test_directory);
+    $full_path = $this->getWorkspaceDirectory() . '/' . $test_directory;
+    $files = [
+      'autoload.php',
+      'composer.json',
+      'index.php',
+      'README.txt',
+      '.git',
+      '.ht.router.php',
+    ];
+    foreach ($files as $file) {
+      $this->assertFileExists($full_path . '/' . $file);
+    }
+  }
+
+  /**
+   * Ensure we're not copying directories we wish to exclude.
+   *
+   * @covers ::copyCodebase
+   */
+  public function testCopyCodebaseExclude() {
+    // Create a virtual file system containing only items that should be
+    // excluded.
+    vfsStream::setup('drupal', NULL, [
+      'sites' => [
+        'default' => [
+          'files' => [
+            'a_file.txt' => 'some file.',
+          ],
+          'settings.php' => '<?php $settings = stuff;',
+        ],
+        'simpletest' => [
+          'simpletest_hash' => [
+            'some_results.xml' => '<xml/>',
+          ],
+        ],
+      ],
+      'vendor' => [
+        'composer' => [
+          'composer' => [
+            'installed.json' => '"items": {"things"}',
+          ],
+        ],
+      ],
+    ]);
+
+    // Mock BuildTestBase so that it thinks our VFS is the Drupal root.
+    /** @var \PHPUnit\Framework\MockObject\MockBuilder|\Drupal\BuildTests\Framework\BuildTestBase $base */
+    $base = $this->getMockBuilder(BuildTestBase::class)
+      ->setMethods(['getDrupalRoot'])
+      ->getMockForAbstractClass();
+    $base->expects($this->exactly(2))
+      ->method('getDrupalRoot')
+      ->willReturn(vfsStream::url('drupal'));
+
+    $base->setUp();
+
+    // Perform the copy.
+    $test_directory = 'copied_codebase';
+    $base->copyCodebase(NULL, $test_directory);
+    $full_path = $base->getWorkspaceDirectory() . '/' . $test_directory;
+
+    $this->assertDirectoryExists($full_path);
+    // Use scandir() to determine if our target directory is empty. It should
+    // only contain the system dot directories.
+    $this->assertTrue(
+      ($files = @scandir($full_path)) && count($files) <= 2,
+      'Directory is not empty: ' . implode(', ', $files)
+    );
+
+    $base->tearDown();
+  }
+
+  /**
+   * @covers ::findAvailablePort
+   */
+  public function testPortMany() {
+    $iterator = (new Finder())->in($this->getDrupalRoot())
+      ->ignoreDotFiles(FALSE)
+      ->exclude(['sites/simpletest'])
+      ->path('/^.ht.router.php$/')
+      ->getIterator();
+    $this->copyCodebase($iterator);
+    /** @var \Symfony\Component\Process\Process[] $processes */
+    $processes = [];
+    $count = 15;
+    for ($i = 0; $i <= $count; $i++) {
+      $port = $this->findAvailablePort();
+      $this->assertArrayNotHasKey($port, $processes, 'Port ' . $port . ' was already in use by a process.');
+      $processes[$port] = $this->instantiateServer($port);
+      $this->assertNotEmpty($processes[$port]);
+      $this->assertTrue($processes[$port]->isRunning(), 'Process on port ' . $port . ' is not still running.');
+      $this->assertFalse($this->checkPortIsAvailable($port));
+    }
+  }
+
+}
diff --git a/core/tests/Drupal/BuildTests/Framework/Tests/DrupalMinkClientTest.php b/core/tests/Drupal/BuildTests/Framework/Tests/DrupalMinkClientTest.php
new file mode 100644
index 000000000000..ba9ff2e1378d
--- /dev/null
+++ b/core/tests/Drupal/BuildTests/Framework/Tests/DrupalMinkClientTest.php
@@ -0,0 +1,101 @@
+<?php
+
+namespace Drupal\BuildTests\Framework\Tests;
+
+use Drupal\BuildTests\Framework\DrupalMinkClient;
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\BrowserKit\Response;
+
+/**
+ * Test \Drupal\BuildTests\Framework\DrupalMinkClient.
+ *
+ * This test is adapted from \Symfony\Component\BrowserKit\Tests\ClientTest.
+ *
+ * @coversDefaultClass \Drupal\BuildTests\Framework\DrupalMinkClient
+ *
+ * @group Build
+ */
+class DrupalMinkClientTest extends TestCase {
+
+  /**
+   * @dataProvider getTestsForMetaRefresh
+   * @covers ::getMetaRefreshUrl
+   */
+  public function testFollowMetaRefresh(string $content, string $expectedEndingUrl, bool $followMetaRefresh = TRUE) {
+    $client = new TestClient();
+    $client->followMetaRefresh($followMetaRefresh);
+    $client->setNextResponse(new Response($content));
+    $client->request('GET', 'http://www.example.com/foo/foobar');
+    $this->assertEquals($expectedEndingUrl, $client->getRequest()->getUri());
+  }
+
+  public function getTestsForMetaRefresh() {
+    return [
+      ['<html><head><meta http-equiv="Refresh" content="4" /><meta http-equiv="refresh" content="0; URL=http://www.example.com/redirected"/></head></html>', 'http://www.example.com/redirected'],
+      ['<html><head><meta http-equiv="refresh" content="0;URL=http://www.example.com/redirected"/></head></html>', 'http://www.example.com/redirected'],
+      ['<html><head><meta http-equiv="refresh" content="0;URL=\'http://www.example.com/redirected\'"/></head></html>', 'http://www.example.com/redirected'],
+      ['<html><head><meta http-equiv="refresh" content=\'0;URL="http://www.example.com/redirected"\'/></head></html>', 'http://www.example.com/redirected'],
+      ['<html><head><meta http-equiv="refresh" content="0; URL = http://www.example.com/redirected"/></head></html>', 'http://www.example.com/redirected'],
+      ['<html><head><meta http-equiv="refresh" content="0;URL= http://www.example.com/redirected  "/></head></html>', 'http://www.example.com/redirected'],
+      ['<html><head><meta http-equiv="refresh" content="0;url=http://www.example.com/redirected  "/></head></html>', 'http://www.example.com/redirected'],
+      ['<html><head><noscript><meta http-equiv="refresh" content="0;URL=http://www.example.com/redirected"/></noscript></head></head></html>', 'http://www.example.com/redirected'],
+      // Non-zero timeout should not result in a redirect.
+      ['<html><head><meta http-equiv="refresh" content="4; URL=http://www.example.com/redirected"/></head></html>', 'http://www.example.com/foo/foobar'],
+      ['<html><body></body></html>', 'http://www.example.com/foo/foobar'],
+      // HTML 5 allows the meta tag to be placed in head or body.
+      ['<html><body><meta http-equiv="refresh" content="0;url=http://www.example.com/redirected"/></body></html>', 'http://www.example.com/redirected'],
+      // Valid meta refresh should not be followed if disabled.
+      ['<html><head><meta http-equiv="refresh" content="0;URL=http://www.example.com/redirected"/></head></html>', 'http://www.example.com/foo/foobar', FALSE],
+      'drupal-1' => ['<html><head><meta http-equiv="Refresh" content="0; URL=/update.php/start?id=2&op=do_nojs" /></body></html>', 'http://www.example.com/update.php/start?id=2&op=do_nojs'],
+      'drupal-2' => ['<html><head><noscript><meta http-equiv="Refresh" content="0; URL=/update.php/start?id=2&op=do_nojs" /></noscript></body></html>', 'http://www.example.com/update.php/start?id=2&op=do_nojs'],
+    ];
+  }
+
+  /**
+   * @covers ::request
+   */
+  public function testBackForwardMetaRefresh() {
+    $client = new TestClient();
+    $client->followMetaRefresh();
+
+    // First request.
+    $client->request('GET', 'http://www.example.com/first-page');
+
+    $content = '<html><head><meta http-equiv="Refresh" content="0; URL=/refreshed" /></body></html>';
+    $client->setNextResponse(new Response($content, 200));
+    $client->request('GET', 'http://www.example.com/refresh-from-here');
+
+    $this->assertEquals('http://www.example.com/refreshed', $client->getRequest()->getUri());
+
+    $client->back();
+    $this->assertEquals('http://www.example.com/first-page', $client->getRequest()->getUri());
+
+    $client->forward();
+    $this->assertEquals('http://www.example.com/refreshed', $client->getRequest()->getUri());
+  }
+
+}
+
+/**
+ * Special client that can return a given response on the first doRequest().
+ */
+class TestClient extends DrupalMinkClient {
+
+  protected $nextResponse = NULL;
+
+  public function setNextResponse(Response $response) {
+    $this->nextResponse = $response;
+  }
+
+  protected function doRequest($request) {
+    if (NULL === $this->nextResponse) {
+      return new Response();
+    }
+
+    $response = $this->nextResponse;
+    $this->nextResponse = NULL;
+
+    return $response;
+  }
+
+}
diff --git a/core/tests/Drupal/BuildTests/Framework/Tests/ExternalCommandRequirementTest.php b/core/tests/Drupal/BuildTests/Framework/Tests/ExternalCommandRequirementTest.php
new file mode 100644
index 000000000000..b4f6170fc1ca
--- /dev/null
+++ b/core/tests/Drupal/BuildTests/Framework/Tests/ExternalCommandRequirementTest.php
@@ -0,0 +1,182 @@
+<?php
+
+namespace Drupal\BuildTests\Framework\Tests;
+
+use Drupal\BuildTests\Framework\ExternalCommandRequirementsTrait;
+use PHPUnit\Framework\SkippedTestError;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @coversDefaultClass \Drupal\BuildTests\Framework\ExternalCommandRequirementsTrait
+ * @group Build
+ */
+class ExternalCommandRequirementTest extends TestCase {
+
+  /**
+   * @covers ::checkExternalCommandRequirements
+   */
+  public function testCheckExternalCommandRequirementsNotAvailable() {
+    $requires = new UsesCommandRequirements();
+    $ref_check_requirements = new \ReflectionMethod($requires, 'checkExternalCommandRequirements');
+    $ref_check_requirements->setAccessible(TRUE);
+
+    // Use a try/catch block because otherwise PHPUnit might think this test is
+    // legitimately skipped.
+    try {
+      $ref_check_requirements->invokeArgs($requires, [
+        ['externalCommand not_available', 'externalCommand available_command'],
+      ]);
+      $this->fail('Unavailable external command requirement should throw a skipped test error exception.');
+    }
+    catch (SkippedTestError $exception) {
+      $this->assertEquals('Required external commands: not_available', $exception->getMessage());
+    }
+  }
+
+  /**
+   * @covers ::checkExternalCommandRequirements
+   */
+  public function testCheckExternalCommandRequirementsAvailable() {
+    $requires = new UsesCommandRequirements();
+    $ref_check_requirements = new \ReflectionMethod($requires, 'checkExternalCommandRequirements');
+    $ref_check_requirements->setAccessible(TRUE);
+
+    // Use a try/catch block because otherwise PHPUnit might think this test is
+    // legitimately skipped.
+    try {
+      $this->assertNull(
+        $ref_check_requirements->invokeArgs($requires, [['externalCommand available_command']])
+      );
+    }
+    catch (SkippedTestError $exception) {
+      $this->fail(sprintf('The external command should be available: %s', $exception->getMessage()));
+    }
+  }
+
+  /**
+   * @covers ::checkClassCommandRequirements
+   */
+  public function testClassRequiresAvailable() {
+    $requires = new ClassRequiresAvailable();
+    $ref_check = new \ReflectionMethod($requires, 'checkClassCommandRequirements');
+    $ref_check->setAccessible(TRUE);
+    // Use a try/catch block because otherwise PHPUnit might think this test is
+    // legitimately skipped.
+    try {
+      $this->assertNull($ref_check->invoke($requires));
+    }
+    catch (SkippedTestError $exception) {
+      $this->fail(sprintf('The external command should be available: %s', $exception->getMessage()));
+    }
+  }
+
+  /**
+   * @covers ::checkClassCommandRequirements
+   */
+  public function testClassRequiresUnavailable() {
+    $requires = new ClassRequiresUnavailable();
+    $ref_check = new \ReflectionMethod($requires, 'checkClassCommandRequirements');
+    $ref_check->setAccessible(TRUE);
+    // Use a try/catch block because otherwise PHPUnit might think this test is
+    // legitimately skipped.
+    try {
+      $this->assertNull($ref_check->invoke($requires));
+      $this->fail('Unavailable external command requirement should throw a skipped test error exception.');
+    }
+    catch (SkippedTestError $exception) {
+      $this->assertEquals('Required external commands: unavailable_command', $exception->getMessage());
+    }
+  }
+
+  /**
+   * @covers ::checkMethodCommandRequirements
+   */
+  public function testMethodRequiresAvailable() {
+    $requires = new MethodRequires();
+    $ref_check = new \ReflectionMethod($requires, 'checkMethodCommandRequirements');
+    $ref_check->setAccessible(TRUE);
+    // Use a try/catch block because otherwise PHPUnit might think this test is
+    // legitimately skipped.
+    try {
+      $this->assertNull($ref_check->invoke($requires, 'testRequiresAvailable'));
+    }
+    catch (SkippedTestError $exception) {
+      $this->fail(sprintf('The external command should be available: %s', $exception->getMessage()));
+    }
+  }
+
+  /**
+   * @covers ::checkMethodCommandRequirements
+   */
+  public function testMethodRequiresUnavailable() {
+    $requires = new MethodRequires();
+    $ref_check = new \ReflectionMethod($requires, 'checkMethodCommandRequirements');
+    $ref_check->setAccessible(TRUE);
+    // Use a try/catch block because otherwise PHPUnit might think this test is
+    // legitimately skipped.
+    try {
+      $this->assertNull($ref_check->invoke($requires, 'testRequiresUnavailable'));
+      $this->fail('Unavailable external command requirement should throw a skipped test error exception.');
+    }
+    catch (SkippedTestError $exception) {
+      $this->assertEquals('Required external commands: unavailable_command', $exception->getMessage());
+    }
+  }
+
+}
+
+class UsesCommandRequirements {
+
+  use ExternalCommandRequirementsTrait;
+
+  protected static function externalCommandIsAvailable($command) {
+    return in_array($command, ['available_command']);
+  }
+
+}
+
+/**
+ * @requires externalCommand available_command
+ */
+class ClassRequiresAvailable {
+
+  use ExternalCommandRequirementsTrait;
+
+  protected static function externalCommandIsAvailable($command) {
+    return in_array($command, ['available_command']);
+  }
+
+}
+
+/**
+ * @requires externalCommand unavailable_command
+ */
+class ClassRequiresUnavailable {
+
+  use ExternalCommandRequirementsTrait;
+
+}
+
+class MethodRequires {
+
+  use ExternalCommandRequirementsTrait;
+
+  /**
+   * @requires externalCommand available_command
+   */
+  public function testRequiresAvailable() {
+
+  }
+
+  /**
+   * @requires externalCommand unavailable_command
+   */
+  public function testRequiresUnavailable() {
+
+  }
+
+  protected static function externalCommandIsAvailable($command) {
+    return in_array($command, ['available_command']);
+  }
+
+}
diff --git a/core/tests/TestSuites/BuildTestSuite.php b/core/tests/TestSuites/BuildTestSuite.php
new file mode 100644
index 000000000000..162377641657
--- /dev/null
+++ b/core/tests/TestSuites/BuildTestSuite.php
@@ -0,0 +1,27 @@
+<?php
+
+namespace Drupal\Tests\TestSuites;
+
+require_once __DIR__ . '/TestSuiteBase.php';
+
+/**
+ * Discovers tests for the build test suite.
+ */
+class BuildTestSuite extends TestSuiteBase {
+
+  /**
+   * Factory method which loads up a suite with all build tests.
+   *
+   * @return static
+   *   The test suite.
+   */
+  public static function suite() {
+    $root = dirname(dirname(dirname(__DIR__)));
+
+    $suite = new static('build');
+    $suite->addTestsBySuiteNamespace($root, 'Build');
+
+    return $suite;
+  }
+
+}
diff --git a/core/tests/bootstrap.php b/core/tests/bootstrap.php
index 3ea857115efb..838ac06b78fa 100644
--- a/core/tests/bootstrap.php
+++ b/core/tests/bootstrap.php
@@ -137,6 +137,7 @@ function drupal_phpunit_populate_class_loader() {
   $loader = require __DIR__ . '/../../autoload.php';
 
   // Start with classes in known locations.
+  $loader->add('Drupal\\BuildTests', __DIR__);
   $loader->add('Drupal\\Tests', __DIR__);
   $loader->add('Drupal\\TestSite', __DIR__);
   $loader->add('Drupal\\KernelTests', __DIR__);
-- 
GitLab