Initial Drupal 11 with DDEV setup

This commit is contained in:
gluebox
2025-10-08 11:39:17 -04:00
commit 89ef74b305
25344 changed files with 2599172 additions and 0 deletions

View File

@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace Drupal\TestTools\Comparator;
use Drupal\Component\Render\MarkupInterface;
use SebastianBergmann\Comparator\Comparator;
/**
* Compares MarkupInterface objects for equality.
*/
class MarkupInterfaceComparator extends Comparator {
/**
* {@inheritdoc}
*/
public function accepts($expected, $actual): bool {
// If at least one argument is a MarkupInterface object, we take over and
// convert to strings before comparing.
return ($expected instanceof MarkupInterface && $actual instanceof MarkupInterface) ||
($expected instanceof MarkupInterface && is_scalar($actual)) ||
(is_scalar($expected) && $actual instanceof MarkupInterface);
}
/**
* {@inheritdoc}
*/
public function assertEquals($expected, $actual, $delta = 0.0, $canonicalize = FALSE, $ignoreCase = FALSE): void {
if (is_scalar($expected) && is_scalar($actual)) {
throw new \LogicException(__METHOD__ . '() should not be called directly. Use TestCase::assertEquals() instead');
}
if (is_array($expected) || is_array($actual)) {
throw new \InvalidArgumentException('Expected and actual arguments passed to ' . __METHOD__ . '() must not be arrays');
}
$expected_safe = (string) $expected;
$actual_safe = (string) $actual;
$expected_safe_stripped = strip_tags($expected_safe);
$actual_safe_stripped = strip_tags($actual_safe);
if (!($expected instanceof MarkupInterface && $actual instanceof MarkupInterface)) {
if ($expected_safe !== $expected_safe_stripped && $actual_safe !== $actual_safe_stripped) {
throw new \RuntimeException("Cannot compare markup between MarkupInterface objects and plain strings");
}
}
$comparator = $this->factory()->getComparatorFor($expected_safe_stripped, $actual_safe_stripped);
$comparator->assertEquals($expected_safe_stripped, $actual_safe_stripped, $delta, $canonicalize, $ignoreCase);
}
}

View File

@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace Drupal\TestTools\ErrorHandler;
use Drupal\TestTools\Extension\DeprecationBridge\DeprecationHandler;
use PHPUnit\Event\Code\NoTestCaseObjectOnCallStackException;
use PHPUnit\Runner\ErrorHandler as PhpUnitErrorHandler;
/**
* Drupal's PHPUnit base error handler.
*
* This code works in coordination with DeprecationHandler.
*
* This error handler is registered during PHPUnit's runner bootstrap, and is
* essentially used to capture deprecations occurring before tests are run (for
* example, deprecations triggered by the DebugClassloader). When test runs
* are prepared, a test specific TestErrorHandler is activated instead.
*
* @see \Drupal\TestTools\Extension\DeprecationBridge\DeprecationHandler
*
* @internal
*/
final class BootstrapErrorHandler {
/**
* @param \PHPUnit\Runner\ErrorHandler $phpUnitErrorHandler
* An instance of PHPUnit's runner own error handler. Any error not
* managed here will fall back to it.
*/
public function __construct(
private readonly PhpUnitErrorHandler $phpUnitErrorHandler,
) {
}
/**
* Executes when the object is called as a function.
*
* @param int $errorNumber
* The level of the error raised.
* @param string $errorString
* The error message.
* @param string $errorFile
* The filename that the error was raised in.
* @param int $errorLine
* The line number the error was raised at.
*
* @return bool
* TRUE to stop error handling, FALSE to let the normal error handler
* continue.
*/
public function __invoke(int $errorNumber, string $errorString, string $errorFile, int $errorLine): bool {
if (!DeprecationHandler::isEnabled()) {
throw new \RuntimeException(__METHOD__ . '() must not be called if the deprecation handler is not enabled.');
}
// We collect a deprecation no matter what.
if (E_USER_DEPRECATED === $errorNumber || E_DEPRECATED === $errorNumber) {
$prefix = (error_reporting() & $errorNumber) ? 'Unsilenced deprecation: ' : '';
DeprecationHandler::collectActualDeprecation($prefix . $errorString);
}
// If the deprecation handled is one of those in the ignore list, we keep
// running.
if ((E_USER_DEPRECATED === $errorNumber || E_DEPRECATED === $errorNumber) && DeprecationHandler::isIgnoredDeprecation($errorString)) {
return TRUE;
}
// In all other cases (errors, warnings, deprecations to be reported), we
// fall back to PHPUnit's error handler, an instance of which was created
// when this error handler was created.
try {
call_user_func($this->phpUnitErrorHandler, $errorNumber, $errorString, $errorFile, $errorLine);
}
catch (NoTestCaseObjectOnCallStackException) {
// If we end up here, it's likely because a test's processing has
// finished already and we are processing an error that occurred while
// dealing with STDOUT rewinding or truncating. Do nothing.
}
return TRUE;
}
}

View File

@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace Drupal\TestTools\ErrorHandler;
use Drupal\TestTools\Extension\DeprecationBridge\DeprecationHandler;
use PHPUnit\Framework\TestCase;
/**
* Drupal's PHPUnit test level error handler.
*
* This code works in coordination with DeprecationHandler.
*
* This error handler is registered during the preparation of a PHPUnit's test,
* and is essentially used to capture deprecations occurring during test
* executions. When test runs are torn down, the more generic
* BootstrapErrorHandler is restored.
*
* @see \Drupal\TestTools\Extension\DeprecationBridge\DeprecationHandler
*
* @internal
*/
final class TestErrorHandler {
/**
* @param \Drupal\TestTools\ErrorHandler\BootstrapErrorHandler $parentHandler
* The parent error handler.
* @param \PHPUnit\Framework\TestCase $testCase
* The test case being executed.
*/
public function __construct(
private readonly BootstrapErrorHandler $parentHandler,
private readonly TestCase $testCase,
) {
}
/**
* Executes when the object is called as a function.
*
* @param int $errorNumber
* The level of the error raised.
* @param string $errorString
* The error message.
* @param string $errorFile
* The filename that the error was raised in.
* @param int $errorLine
* The line number the error was raised at.
*
* @return bool
* TRUE to stop error handling, FALSE to let the normal error handler
* continue.
*/
public function __invoke(int $errorNumber, string $errorString, string $errorFile, int $errorLine): bool {
if (!DeprecationHandler::isEnabled()) {
throw new \RuntimeException(__METHOD__ . '() must not be called if the deprecation handler is not enabled.');
}
// We are within a test execution. If we have a deprecation and the test is
// a deprecation test, than we just collect the deprecation and return to
// execution, since deprecations are expected.
if ((E_USER_DEPRECATED === $errorNumber || E_DEPRECATED === $errorNumber) && DeprecationHandler::isDeprecationTest($this->testCase)) {
$prefix = (error_reporting() & $errorNumber) ? 'Unsilenced deprecation: ' : '';
DeprecationHandler::collectActualDeprecation($prefix . $errorString);
return TRUE;
}
// In all other cases (errors, warnings, deprecations in normal tests), we
// fall back to the parent error handler, which is the one that was
// registered in the test runner bootstrap (BootstrapErrorHandler).
call_user_func($this->parentHandler, $errorNumber, $errorString, $errorFile, $errorLine);
return TRUE;
}
}

View File

@ -0,0 +1,254 @@
<?php
declare(strict_types=1);
namespace Drupal\TestTools\Extension\DeprecationBridge;
use PHPUnit\Framework\TestCase;
/**
* Drupal's PHPUnit extension to manage code deprecation.
*
* This class is a replacement for symfony/phpunit-bridge that does not
* support PHPUnit 10. In the future this extension might be dropped if
* PHPUnit adds support for all deprecation management needs.
*
* @internal
*/
final class DeprecationHandler {
/**
* Indicates if the extension is enabled.
*/
private static bool $enabled = FALSE;
/**
* A list of deprecation messages that should be ignored if detected.
*
* @var list<string>
*/
private static array $deprecationIgnorePatterns = [];
/**
* A list of expected deprecation messages.
*
* @var list<string>
*/
private static array $expectedDeprecations = [];
/**
* A list of deprecation messages collected during test run.
*
* @var list<string>
*/
private static array $collectedDeprecations = [];
/**
* This class should not be instantiated.
*/
private function __construct() {
throw new \LogicException(__CLASS__ . ' should not be instantiated');
}
/**
* Returns the extension configuration.
*
* For historical reasons, the configuration is stored in the
* SYMFONY_DEPRECATIONS_HELPER environment variable.
*
* @return array|false
* An array of configuration variables, of FALSE if the extension is
* disabled.
*/
public static function getConfiguration(): array|FALSE {
$environmentVariable = getenv('SYMFONY_DEPRECATIONS_HELPER');
if ($environmentVariable === 'disabled') {
return FALSE;
}
if ($environmentVariable === FALSE) {
// Ensure ignored deprecation patterns listed in .deprecation-ignore.txt
// are considered in testing.
$relativeFilePath = __DIR__ . "/../../../../../.deprecation-ignore.txt";
$deprecationIgnoreFilename = realpath($relativeFilePath);
if (empty($deprecationIgnoreFilename)) {
throw new \InvalidArgumentException(sprintf('The ignoreFile "%s" does not exist.', $relativeFilePath));
}
$environmentVariable = "ignoreFile=$deprecationIgnoreFilename";
}
parse_str($environmentVariable, $configuration);
$environmentVariable = getenv('PHPUNIT_FAIL_ON_PHPUNIT_DEPRECATION');
$phpUnitDeprecationVariable = $environmentVariable !== FALSE ? $environmentVariable : TRUE;
$configuration['failOnPhpunitDeprecation'] = filter_var($phpUnitDeprecationVariable, \FILTER_VALIDATE_BOOLEAN);
return $configuration;
}
/**
* Determines if the extension is enabled.
*
* @return bool
* TRUE if enabled, FALSE if disabled.
*/
public static function isEnabled(): bool {
return self::$enabled;
}
/**
* Initializes the extension.
*
* @param string|null $ignoreFile
* The path to a file containing ignore patterns for deprecations.
*/
public static function init(?string $ignoreFile = NULL): void {
if (self::isEnabled()) {
throw new \LogicException(__CLASS__ . ' is already initialized');
}
// Load the deprecation ignore patterns from the specified file.
if ($ignoreFile && !self::$deprecationIgnorePatterns) {
if (!is_file($ignoreFile)) {
throw new \InvalidArgumentException(sprintf('The ignoreFile "%s" does not exist.', $ignoreFile));
}
set_error_handler(static function ($t, $m) use ($ignoreFile, &$line) {
throw new \RuntimeException(sprintf('Invalid pattern found in "%s" on line "%d"', $ignoreFile, 1 + $line) . substr($m, 12));
});
try {
foreach (file($ignoreFile) as $line => $pattern) {
if ((trim($pattern)[0] ?? '#') !== '#') {
preg_match($pattern, '');
self::$deprecationIgnorePatterns[] = $pattern;
}
}
}
finally {
restore_error_handler();
}
}
// Mark the extension as enabled.
self::$enabled = TRUE;
}
/**
* Resets the extension.
*
* The extension should be reset at the beginning of each test run to ensure
* matching of expected and actual deprecations.
*/
public static function reset(): void {
if (!self::isEnabled()) {
return;
}
self::$expectedDeprecations = [];
self::$collectedDeprecations = [];
}
/**
* Adds an expected deprecation.
*
* Tests will expect deprecations during the test execution; at the end of
* each test run, collected deprecations are checked against the expected
* ones.
*
* @param string $message
* The expected deprecation message.
*/
public static function expectDeprecation(string $message): void {
if (!self::isEnabled()) {
return;
}
self::$expectedDeprecations[] = $message;
}
/**
* Returns all expected deprecations.
*
* @return list<string>
* The expected deprecation messages.
*/
public static function getExpectedDeprecations(): array {
if (!self::isEnabled()) {
throw new \LogicException(__CLASS__ . ' is not initialized');
}
return self::$expectedDeprecations;
}
/**
* Collects an actual deprecation.
*
* Tests will expect deprecations during the test execution; at the end of
* each test run, collected deprecations are checked against the expected
* ones.
*
* @param string $message
* The actual deprecation message triggered via trigger_error().
*/
public static function collectActualDeprecation(string $message): void {
if (!self::isEnabled()) {
return;
}
self::$collectedDeprecations[] = $message;
}
/**
* Returns all collected deprecations.
*
* @return list<string>
* The collected deprecation messages.
*/
public static function getCollectedDeprecations(): array {
if (!self::isEnabled()) {
throw new \LogicException(__CLASS__ . ' is not initialized');
}
return self::$collectedDeprecations;
}
/**
* Determines if an actual deprecation should be ignored.
*
* Deprecations that match the patterns included in the ignore file should
* be ignored.
*
* @param string $deprecationMessage
* The actual deprecation message triggered via trigger_error().
*/
public static function isIgnoredDeprecation(string $deprecationMessage): bool {
if (!self::$deprecationIgnorePatterns) {
return FALSE;
}
$result = @preg_filter(self::$deprecationIgnorePatterns, '$0', $deprecationMessage);
if (preg_last_error() !== \PREG_NO_ERROR) {
throw new \RuntimeException(preg_last_error_msg());
}
return (bool) $result;
}
/**
* Determines if a test case is a deprecation test.
*
* Deprecation tests are those that are annotated with '@group legacy' or
* that have a '#[IgnoreDeprecations]' attribute.
*
* @param \PHPUnit\Framework\TestCase $testCase
* The test case being executed.
*/
public static function isDeprecationTest(TestCase $testCase): bool {
return $testCase->valueObjectForEvents()->metadata()->isIgnoreDeprecations()->isNotEmpty() || self::isTestInLegacyGroup($testCase);
}
/**
* Determines if a test case is part of the 'legacy' group.
*
* @param \PHPUnit\Framework\TestCase $testCase
* The test case being executed.
*/
private static function isTestInLegacyGroup(TestCase $testCase): bool {
$groups = [];
foreach ($testCase->valueObjectForEvents()->metadata()->isGroup() as $metadata) {
$groups[] = $metadata->groupName();
}
return in_array('legacy', $groups, TRUE);
}
}

View File

@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace Drupal\TestTools\Extension\DeprecationBridge;
use Drupal\Core\Utility\Error;
use Drupal\TestTools\ErrorHandler\TestErrorHandler;
use PHPUnit\Framework\Attributes\After;
use PHPUnit\Framework\Attributes\Before;
/**
* A trait to include in Drupal tests to manage expected deprecations.
*
* This code works in coordination with DeprecationHandler.
*
* This trait is a replacement for symfony/phpunit-bridge that is not
* supporting PHPUnit 10. In the future this extension might be dropped if
* PHPUnit will support all deprecation management needs.
*
* @see \Drupal\TestTools\Extension\DeprecationBridge\DeprecationHandler
*
* @internal
*/
trait ExpectDeprecationTrait {
/**
* Sets up the test error handler.
*
* This method is run before each test's ::setUp() method, and when the
* DeprecationHandler is active, resets the extension to be able to collect
* the test's deprecations, and sets TestErrorHandler as the current error
* handler.
*
* @see \Drupal\TestTools\ErrorHandler\TestErrorHandler
*/
#[Before]
public function setUpErrorHandler(): void {
if (!DeprecationHandler::isEnabled()) {
return;
}
DeprecationHandler::reset();
set_error_handler(new TestErrorHandler(Error::currentErrorHandler(), $this));
}
/**
* Tears down the test error handler.
*
* This method is run after each test's ::tearDown() method, and checks if
* collected deprecations match the expectations; it also resets the error
* handler to the one set prior of the change made by ::setUpErrorHandler().
*/
#[After]
public function tearDownErrorHandler(): void {
if (!DeprecationHandler::isEnabled()) {
return;
}
// We expect that the current error handler is the one set by
// ::setUpErrorHandler() prior to the start of the test execution. If not,
// the error handler was changed during the test execution but not properly
// restored during ::tearDown().
$handler = Error::currentErrorHandler();
if (!$handler instanceof TestErrorHandler) {
throw new \RuntimeException(sprintf('%s registered its own error handler without restoring the previous one before or during tear down. This can cause unpredictable test results. Ensure the test cleans up after itself.', $this->name()));
}
restore_error_handler();
// Checks if collected deprecations match the expectations.
if (DeprecationHandler::getExpectedDeprecations()) {
$prefix = "@expectedDeprecation:\n";
$expDep = $prefix . '%A ' . implode("\n%A ", DeprecationHandler::getExpectedDeprecations()) . "\n%A";
$actDep = $prefix . ' ' . implode("\n ", DeprecationHandler::getCollectedDeprecations()) . "\n";
$this->assertStringMatchesFormat($expDep, $actDep);
}
}
/**
* Adds an expected deprecation.
*
* @param string $message
* The expected deprecation message.
*/
public function expectDeprecation(string $message): void {
if (!DeprecationHandler::isDeprecationTest($this)) {
throw new \RuntimeException('expectDeprecation() can only be called from tests marked with #[IgnoreDeprecations] or \'@group legacy\'');
}
if (!DeprecationHandler::isEnabled()) {
return;
}
DeprecationHandler::expectDeprecation($message);
}
}

View File

@ -0,0 +1,244 @@
<?php
declare(strict_types=1);
namespace Drupal\TestTools\Extension\Dump;
use PHPUnit\Event\TestRunner\Finished as TestRunnerFinished;
use PHPUnit\Framework\TestCase;
use PHPUnit\Runner\Extension\Extension;
use PHPUnit\Runner\Extension\Facade;
use PHPUnit\Runner\Extension\ParameterCollection;
use PHPUnit\TextUI\Configuration\Configuration;
use Symfony\Component\VarDumper\Cloner\VarCloner;
use Symfony\Component\VarDumper\Dumper\CliDumper;
/**
* Drupal's extension for printing dump() output results.
*
* @internal
*/
final class DebugDump implements Extension {
/**
* The path to the dump staging file.
*/
private static string $stagingFilePath;
/**
* Whether colors should be used for printing.
*/
private static bool $colors = FALSE;
/**
* Whether the caller of dump should be included in the report.
*/
private static bool $printCaller = FALSE;
/**
* {@inheritdoc}
*/
public function bootstrap(
Configuration $configuration,
Facade $facade,
ParameterCollection $parameters,
): void {
// Determine staging file path.
self::$stagingFilePath = tempnam(sys_get_temp_dir(), 'dpd');
// Determine color output.
$colors = $parameters->has('colors') ? $parameters->get('colors') : FALSE;
self::$colors = filter_var($colors, \FILTER_VALIDATE_BOOLEAN);
// Print caller.
$printCaller = $parameters->has('printCaller') ? $parameters->get('printCaller') : FALSE;
self::$printCaller = filter_var($printCaller, \FILTER_VALIDATE_BOOLEAN);
// Set the environment variable with the configuration.
$config = json_encode([
'stagingFilePath' => self::$stagingFilePath,
'colors' => self::$colors,
'printCaller' => self::$printCaller,
]);
putenv('DRUPAL_PHPUNIT_DUMPER_CONFIG=' . $config);
$facade->registerSubscriber(new TestRunnerFinishedSubscriber($this));
}
/**
* Determines if the extension is enabled.
*
* @return bool
* TRUE if enabled, FALSE if disabled.
*/
public static function isEnabled(): bool {
return getenv('DRUPAL_PHPUNIT_DUMPER_CONFIG') !== FALSE;
}
/**
* A CLI handler for \Symfony\Component\VarDumper\VarDumper.
*
* @param mixed $var
* The variable to be dumped.
*/
public static function cliHandler(mixed $var): void {
if (!self::isEnabled()) {
return;
}
$config = (array) json_decode(getenv('DRUPAL_PHPUNIT_DUMPER_CONFIG'));
$caller = self::getCaller();
$cloner = new VarCloner();
$dumper = new CliDumper();
$dumper->setColors($config['colors']);
$dump = [];
$dumper->dump(
$cloner->cloneVar($var),
function ($line, $depth, $indent_pad) use (&$dump) {
// A negative depth means "end of dump".
if ($depth >= 0) {
// Adds a two spaces indentation to the line.
$dump[] = str_repeat($indent_pad, $depth) . $line;
}
}
);
file_put_contents(
$config['stagingFilePath'],
self::encodeDump($caller['test']->id(), $caller['file'], $caller['line'], $dump) . "\n",
FILE_APPEND,
);
}
/**
* Encodes the dump for storing.
*
* @param string $testId
* The id of the test from where the dump was called.
* @param string|null $file
* The path of the file from where the dump was called.
* @param int|null $line
* The line number from where the dump was called.
* @param array $dump
* The dump as an array of lines.
*
* @return string
* An encoded string.
*/
private static function encodeDump(string $testId, ?string $file, ?int $line, array $dump): string {
$data = [
'test' => $testId,
'file' => $file,
'line' => $line,
'dump' => $dump,
];
$jsonData = json_encode($data);
return base64_encode($jsonData);
}
/**
* Decodes a dump retrieved from storage.
*
* @param string $encodedData
* An encoded string.
*
* @return array{test: string, file: string|null, line: int|null, dump: string[]}
* An encoded string.
*/
private static function decodeDump(string $encodedData): array {
$jsonData = base64_decode($encodedData);
return (array) json_decode($jsonData);
}
/**
* Returns information about the caller of dump().
*
* @return array{test: \PHPUnit\Framework\Event\Code\TestMethod, file: string|null, line: int|null}
* Caller information.
*/
private static function getCaller(): array {
$backtrace = debug_backtrace();
while (!isset($backtrace[0]['function']) || $backtrace[0]['function'] !== 'dump') {
array_shift($backtrace);
}
$call['file'] = $backtrace[1]['file'] ?? NULL;
$call['line'] = $backtrace[1]['line'] ?? NULL;
while (!isset($backtrace[0]['object']) || !($backtrace[0]['object'] instanceof TestCase)) {
array_shift($backtrace);
}
$call['test'] = $backtrace[0]['object']->valueObjectForEvents();
return $call;
}
/**
* Retrieves dumps from storage.
*
* @return array{string, array{file: string|null, line: int|null, dump: string[]}}
* Caller information.
*/
public static function getDumps(): array {
if (!self::isEnabled()) {
return [];
}
$config = (array) json_decode(getenv('DRUPAL_PHPUNIT_DUMPER_CONFIG'));
$contents = rtrim(file_get_contents($config['stagingFilePath']));
if (empty($contents)) {
return [];
}
$encodedDumps = explode("\n", $contents);
$dumps = [];
foreach ($encodedDumps as $encodedDump) {
$dump = self::decodeDump($encodedDump);
$test = $dump['test'];
unset($dump['test']);
$dumps[$test][] = $dump;
}
return $dumps;
}
/**
* Prints the dumps generated during the test.
*/
public function testRunnerFinished(TestRunnerFinished $event): void {
$dumps = self::getDumps();
// Cleanup.
unlink(self::$stagingFilePath);
putenv('DRUPAL_PHPUNIT_DUMPER_CONFIG');
if ($dumps === []) {
return;
}
print "\n\n";
print "dump() output\n";
print "-------------\n\n";
foreach ($dumps as $testId => $testDumps) {
if (self::$printCaller) {
print $testId . "\n";
}
foreach ($testDumps as $dump) {
if (self::$printCaller) {
print "in " . $dump['file'] . ", line " . $dump['line'] . ":\n";
}
foreach ($dump['dump'] as $line) {
print $line . "\n";
}
if (self::$printCaller) {
print "\n";
}
}
if (self::$printCaller) {
print "\n";
}
}
}
}

View File

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Drupal\TestTools\Extension\Dump;
use PHPUnit\Event\TestRunner\Finished;
use PHPUnit\Event\TestRunner\FinishedSubscriber;
/**
* Event subscriber notifying end of test runner execution to HTML logging.
*
* @internal
*/
final class TestRunnerFinishedSubscriber implements FinishedSubscriber {
public function __construct(
private readonly DebugDump $dump,
) {
}
public function notify(Finished $event): void {
$this->dump->testRunnerFinished($event);
}
}

View File

@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
namespace Drupal\TestTools\Extension\HtmlLogging;
use PHPUnit\Event\TestRunner\Finished as TestRunnerFinished;
use PHPUnit\Event\TestRunner\Started as TestRunnerStarted;
use PHPUnit\Runner\Extension\Extension;
use PHPUnit\Runner\Extension\Facade;
use PHPUnit\Runner\Extension\ParameterCollection;
use PHPUnit\TextUI\Configuration\Configuration;
/**
* Drupal's extension for providing HTML output results for functional tests.
*
* @internal
*/
final class HtmlOutputLogger implements Extension {
/**
* The status of the extension.
*/
private bool $enabled = FALSE;
/**
* A file with list of links to HTML pages generated.
*/
private ?string $browserOutputFile = NULL;
/**
* A file with list of links to HTML pages generated.
*/
private string $outputDirectory;
/**
* Verbosity of the final report.
*
* If TRUE, a list of links generated will be output at the end of the test
* run; if FALSE, only a summary with the count of pages generated.
*/
private bool $outputVerbose;
/**
* {@inheritdoc}
*/
public function bootstrap(
Configuration $configuration,
Facade $facade,
ParameterCollection $parameters,
): void {
// Determine output directory.
$envDirectory = getenv('BROWSERTEST_OUTPUT_DIRECTORY');
if ($envDirectory === "") {
print "HTML output disabled by BROWSERTEST_OUTPUT_DIRECTORY = ''.\n\n";
return;
}
elseif ($envDirectory !== FALSE) {
$directory = $envDirectory;
}
elseif ($parameters->has('outputDirectory')) {
$directory = $parameters->get('outputDirectory');
}
else {
print "HTML output directory not specified.\n\n";
return;
}
$realDirectory = realpath($directory);
if ($realDirectory === FALSE || !is_dir($realDirectory) || !is_writable($realDirectory)) {
print "HTML output directory {$directory} is not a writable directory.\n\n";
return;
}
$this->outputDirectory = $realDirectory;
// Determine output verbosity.
$envVerbose = getenv('BROWSERTEST_OUTPUT_VERBOSE');
if ($envVerbose !== FALSE) {
$verbose = $envVerbose;
}
elseif ($parameters->has('verbose')) {
$verbose = $parameters->get('verbose');
}
else {
$verbose = FALSE;
}
$this->outputVerbose = filter_var($verbose, \FILTER_VALIDATE_BOOLEAN);
$facade->registerSubscriber(new TestRunnerStartedSubscriber($this));
$facade->registerSubscriber(new TestRunnerFinishedSubscriber($this));
$this->enabled = TRUE;
}
/**
* Logs a link to a generated HTML page.
*
* @param string $logEntry
* A link to a generated HTML page, should not contain a trailing newline.
*
* @throws \RuntimeException
*/
public static function log(string $logEntry): void {
$browserOutputFile = getenv('BROWSERTEST_OUTPUT_FILE');
if ($browserOutputFile === FALSE) {
throw new \RuntimeException("HTML output is not enabled");
}
file_put_contents($browserOutputFile, $logEntry . "\n", FILE_APPEND);
}
/**
* Empties the list of the HTML output created during the test run.
*/
public function testRunnerStarted(TestRunnerStarted $event): void {
if (!$this->enabled) {
throw new \RuntimeException("HTML output is not enabled");
}
// Convert to a canonicalized absolute pathname just in case the current
// working directory is changed.
$this->browserOutputFile = tempnam($this->outputDirectory, 'browser_output_');
if ($this->browserOutputFile) {
touch($this->browserOutputFile);
putenv('BROWSERTEST_OUTPUT_FILE=' . $this->browserOutputFile);
}
else {
// Remove any environment variable.
putenv('BROWSERTEST_OUTPUT_FILE');
throw new \RuntimeException("Unable to create a temporary file in {$this->outputDirectory}.");
}
}
/**
* Prints the list of HTML output generated during the test.
*/
public function testRunnerFinished(TestRunnerFinished $event): void {
if (!$this->enabled) {
throw new \RuntimeException("HTML output is not enabled");
}
$contents = file_get_contents($this->browserOutputFile);
if ($contents) {
print "\n\n";
if ($this->outputVerbose) {
print "HTML output was generated.\n";
print $contents;
}
else {
print "HTML output was generated, " . count(explode("\n", $contents)) . " page(s).\n";
}
}
// No need to keep the file around any more.
unlink($this->browserOutputFile);
putenv('BROWSERTEST_OUTPUT_FILE');
$this->browserOutputFile = NULL;
}
}

View File

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace Drupal\TestTools\Extension\HtmlLogging;
/**
* Base class for PHPUnit event subscribers related to HTML logging.
*
* @internal
*/
abstract class SubscriberBase {
public function __construct(
private readonly HtmlOutputLogger $logger,
) {
}
protected function logger(): HtmlOutputLogger {
return $this->logger;
}
}

View File

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Drupal\TestTools\Extension\HtmlLogging;
use PHPUnit\Event\TestRunner\Finished;
use PHPUnit\Event\TestRunner\FinishedSubscriber;
/**
* Event subscriber notifying end of test runner execution to HTML logging.
*
* @internal
*/
final class TestRunnerFinishedSubscriber extends SubscriberBase implements FinishedSubscriber {
public function notify(Finished $event): void {
$this->logger()->testRunnerFinished($event);
}
}

View File

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Drupal\TestTools\Extension\HtmlLogging;
use PHPUnit\Event\TestRunner\Started;
use PHPUnit\Event\TestRunner\StartedSubscriber;
/**
* Event subscriber notifying beginning of test runner to HTML logging.
*
* @internal
*/
final class TestRunnerStartedSubscriber extends SubscriberBase implements StartedSubscriber {
public function notify(Started $event): void {
$this->logger()->testRunnerStarted($event);
}
}

View File

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace Drupal\TestTools\Extension;
use Drupal\Core\Serialization\Yaml;
/**
* Writes the info file and ensures the mtime changes.
*
* @see \Drupal\Component\FileCache\FileCache
* @see \Drupal\Core\Extension\InfoParser
*/
trait InfoWriterTrait {
/**
* Writes the info file and ensures the mtime changes.
*
* @param string $file_path
* The info file path.
* @param array $info
* The info array.
*
* @return void
* No return value.
*/
private function writeInfoFile(string $file_path, array $info): void {
$mtime = file_exists($file_path) ? filemtime($file_path) : FALSE;
file_put_contents($file_path, Yaml::encode($info));
// Ensure mtime changes.
if ($mtime === filemtime($file_path)) {
touch($file_path, max($mtime + 1, time()));
}
}
}

View File

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Drupal\TestTools\Extension;
use PHPUnit\Framework\Attributes\BeforeClass;
use Symfony\Component\Process\ExecutableFinder;
/**
* Ensures Composer executable is available, skips test otherwise.
*/
trait RequiresComposerTrait {
#[BeforeClass]
public static function requiresComposer(): void {
if (!((new ExecutableFinder())->find('composer'))) {
static::markTestSkipped('This test requires the Composer executable to be accessible.');
}
}
}

View File

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Drupal\TestTools\Extension;
use Drupal\Core\Extension\ModuleHandlerInterface;
/**
* Provides methods to access modules' schema.
*/
class SchemaInspector {
/**
* Returns the module's schema specification.
*
* This function can be used to retrieve a schema specification provided by
* hook_schema(), so it allows you to derive your tables from existing
* specifications.
*
* @param \Drupal\Core\Extension\ModuleHandlerInterface $handler
* The module handler to use for calling schema hook.
* @param string $module
* The module to which the table belongs.
*
* @return array
* An array of schema definition provided by hook_schema().
*
* @see \hook_schema()
*/
public static function getTablesSpecification(ModuleHandlerInterface $handler, string $module): array {
if ($handler->loadInclude($module, 'install')) {
return $handler->invoke($module, 'schema') ?? [];
}
return [];
}
}

View File

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Drupal\TestTools\PhpUnitCompatibility\PhpUnit10;
/**
* Drupal's forward compatibility layer with multiple versions of PHPUnit.
*
* @internal
*/
trait TestCompatibilityTrait {
/**
* Gets @covers defined on the test class.
*
* @return string[]
* An array of classes listed with the @covers annotation.
*/
public function getTestClassCovers(): array {
$ret = [];
foreach ($this->valueObjectForEvents()->metadata()->isCovers()->isClassLevel() as $metadata) {
$ret[] = $metadata->target();
}
return $ret;
}
}

View File

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace Drupal\TestTools\PhpUnitCompatibility\PhpUnit11;
/**
* Drupal's forward compatibility layer with multiple versions of PHPUnit.
*
* @internal
*/
trait TestCompatibilityTrait {
}

View File

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Drupal\TestTools\PhpUnitCompatibility;
use PHPUnit\Runner\Version;
/**
* Helper class to determine information about running PHPUnit version.
*
* This class contains static methods only and is not meant to be instantiated.
*/
final class RunnerVersion {
/**
* This class should not be instantiated.
*/
private function __construct() {
}
/**
* Returns the major version of the PHPUnit runner being used.
*
* @return int
* The major version of the PHPUnit runner being used.
*/
public static function getMajor() {
return (int) explode('.', Version::id())[0];
}
}

View File

@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace Drupal\TestTools;
/**
* Enumeration of JUnit test result statuses.
*/
enum PhpUnitTestCaseJUnitResult: string {
case Pass = 'pass';
case Fail = 'fail';
case Error = 'error';
case Skip = 'skipped';
}

View File

@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
namespace Drupal\TestTools;
use Drupal\Component\Utility\Random as RandomUtility;
/**
* Provides random generator utility static methods.
*/
abstract class Random {
/**
* The random generator.
*/
protected static RandomUtility $randomGenerator;
/**
* Gets the random generator for the utility methods.
*
* @return \Drupal\Component\Utility\Random
* The random generator.
*/
public static function getGenerator(): RandomUtility {
if (!isset(static::$randomGenerator)) {
static::$randomGenerator = new RandomUtility();
}
return static::$randomGenerator;
}
/**
* Generates a pseudo-random string of ASCII characters of codes 32 to 126.
*
* Do not use this method when special characters are not possible (e.g., in
* machine or file names that have already been validated); instead, use
* \Drupal\Tests\RandomGeneratorTrait::randomMachineName(). If $length is
* greater than 3 the random string will include at least one ampersand ('&')
* and at least one greater than ('>') character to ensure coverage for
* special characters and avoid the introduction of random test failures.
*
* @param int $length
* Length of random string to generate.
*
* @return string
* Pseudo-randomly generated unique string including special characters.
*
* @see \Drupal\Component\Utility\Random::string()
*/
public static function string(int $length = 8): string {
if ($length < 4) {
return static::getGenerator()->string($length, TRUE, [static::class, 'stringValidate']);
}
// To prevent the introduction of random test failures, ensure that the
// returned string contains a character that needs to be escaped in HTML by
// injecting an ampersand into it.
$replacement_pos = intval($length / 2);
// Remove 2 from the length to account for the ampersand and greater than
// characters.
$string = static::getGenerator()->string($length - 2, TRUE, [static::class, 'stringValidate']);
return substr_replace($string, '>&', $replacement_pos, 0);
}
/**
* Callback for random string validation.
*
* @param string $string
* The random string to validate.
*
* @return bool
* TRUE if the random string is valid, FALSE if not.
*
* @see \Drupal\Component\Utility\Random::string()
*/
public static function stringValidate(string $string): bool {
// Consecutive spaces causes issues for link validation.
if (preg_match('/\s{2,}/', $string)) {
return FALSE;
}
// Starting or ending with a space means that length might not be what is
// expected.
if (preg_match('/^\s|\s$/', $string)) {
return FALSE;
}
return TRUE;
}
/**
* Generates a unique random string containing letters and numbers.
*
* Do not use this method when testing non validated user input. Instead, use
* \Drupal\Tests\RandomGeneratorTrait::randomString().
*
* @param int $length
* Length of random string to generate.
*
* @return string
* Randomly generated unique string.
*
* @see \Drupal\Component\Utility\Random::name()
*/
public static function machineName(int $length = 8): string {
return static::getGenerator()->machineName($length, TRUE);
}
/**
* Generates a random PHP object.
*
* @param int $size
* The number of random keys to add to the object.
*
* @return object
* The generated object, with the specified number of random keys. Each key
* has a random string value.
*
* @see \Drupal\Component\Utility\Random::object()
*/
public static function object(int $size = 4): \stdClass {
return static::getGenerator()->object($size);
}
}