Newer
Older

Adam G-H
committed
<?php

omkar podey
committed
declare(strict_types = 1);

Adam G-H
committed
namespace Drupal\Tests\package_manager\Kernel;

Adam G-H
committed
use Drupal\Core\DependencyInjection\ContainerBuilder;
use Drupal\Core\Site\Settings;

Adam G-H
committed
use Drupal\KernelTests\KernelTestBase;

Kunal Sachdev
committed
use Drupal\package_manager\Event\PreApplyEvent;

Adam G-H
committed
use Drupal\package_manager\Event\StageEvent;
use Drupal\package_manager\StatusCheckTrait;
use Drupal\package_manager\UnusedConfigFactory;
use Drupal\package_manager\Validator\DiskSpaceValidator;
use Drupal\package_manager\Exception\StageValidationException;

Adam G-H
committed
use Drupal\package_manager\Stage;

Adam G-H
committed
use Drupal\package_manager_bypass\Beginner;

Travis Carden
committed
use Drupal\Tests\package_manager\Traits\AssertPreconditionsTrait;
use Drupal\Tests\package_manager\Traits\FixtureUtilityTrait;

Adam G-H
committed
use Drupal\Tests\package_manager\Traits\ValidationTestTrait;
use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Response;
use GuzzleHttp\Psr7\Utils;
use org\bovigo\vfs\vfsStream;
use PhpTuf\ComposerStager\Domain\Value\Path\PathInterface;
use PhpTuf\ComposerStager\Infrastructure\Factory\Path\PathFactoryInterface;
use PhpTuf\ComposerStager\Infrastructure\Value\Path\AbstractPath;
use Psr\Http\Message\RequestInterface;
use Symfony\Component\DependencyInjection\Definition;

Adam G-H
committed
/**
* Base class for kernel tests of Package Manager's functionality.

Yash Rode
committed
*
* @internal

Adam G-H
committed
*/
abstract class PackageManagerKernelTestBase extends KernelTestBase {

Travis Carden
committed
use AssertPreconditionsTrait;
use FixtureUtilityTrait;
use StatusCheckTrait;

Adam G-H
committed
use ValidationTestTrait;
/**
* The mocked HTTP client that returns metadata about available updates.
*
* We need to preserve this as a class property so that we can re-inject it
* into the container when a rebuild is triggered by module installation.
*
* @var \GuzzleHttp\Client
*
* @see ::register()
*/
private $client;

Adam G-H
committed
/**
* {@inheritdoc}
*/
protected static $modules = [

Ted Bowman
committed
'fixture_manipulator',

Adam G-H
committed
'package_manager',
'package_manager_bypass',
'system',
'update',
'update_test',

Adam G-H
committed
];

Ted Bowman
committed
/**
* The service IDs of any validators to disable.
*
* @var string[]
*/
protected $disableValidators = [];

Ted Bowman
committed
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->installConfig('package_manager');
$this->createVirtualProject();
// The Update module's default configuration must be installed for our

Adam G-H
committed
// fake release metadata to be fetched, and the System module's to ensure
// the site has a name.
$this->installConfig(['system', 'update']);
// Make the update system think that all of System's post-update functions
// have run.
$this->registerPostUpdateFunctions();
}

Adam G-H
committed
/**
* {@inheritdoc}
*/
public function register(ContainerBuilder $container) {
parent::register($container);
// If we previously set up a mock HTTP client in ::setReleaseMetadata(),
// re-inject it into the container.
if ($this->client) {
$container->set('http_client', $this->client);
}
// Ensure that Composer Stager uses the test path factory, which is aware
// of the virtual file system.
$definition = new Definition(TestPathFactory::class);
$class = $definition->getClass();
$container->setDefinition($class, $definition->setPublic(FALSE));
$container->setAlias(PathFactoryInterface::class, $class);
// When a virtual project is used, the disk space validator is replaced with
// a mock. When staged changes are applied, the container is rebuilt, which
// destroys the mocked service and can cause unexpected side effects. The
// 'persist' tag prevents the mock from being destroyed during a container
// rebuild.
// @see ::createVirtualProject()
$container->getDefinition('package_manager.validator.disk_space')
->addTag('persist');

Adam G-H
committed

Ted Bowman
committed
foreach ($this->disableValidators as $service_id) {
if ($container->hasDefinition($service_id)) {
$container->getDefinition($service_id)->clearTag('event_subscriber');
}
}

Adam G-H
committed
}

Adam G-H
committed
/**
* Creates a stage object for testing purposes.

Adam G-H
committed
*
* @return \Drupal\Tests\package_manager\Kernel\TestStage
* A stage object, with test-only modifications.

Adam G-H
committed
*/
protected function createStage(): TestStage {
return new TestStage(
// @todo Remove this in https://www.drupal.org/i/3303167
new UnusedConfigFactory(),

Adam G-H
committed
$this->container->get('package_manager.path_locator'),
$this->container->get('package_manager.beginner'),
$this->container->get('package_manager.stager'),
$this->container->get('package_manager.committer'),

Ted Bowman
committed
$this->container->get('file_system'),

Adam G-H
committed
$this->container->get('event_dispatcher'),

Adam G-H
committed
$this->container->get('tempstore.shared'),
$this->container->get('datetime.time'),
new TestPathFactory(),
$this->container->get('package_manager.failure_marker')

Adam G-H
committed
);
}
/**
* Asserts validation results are returned from a stage life cycle event.
*
* @param \Drupal\package_manager\ValidationResult[] $expected_results
* The expected validation results.
* @param string|null $event_class
* (optional) The class of the event which should return the results. Must
* be passed if $expected_results is not empty.
*
* @return \Drupal\package_manager\Stage
* The stage that was used to collect the validation results.
*/
protected function assertResults(array $expected_results, string $event_class = NULL): Stage {
$stage = $this->createStage();

Adam G-H
committed
try {
$stage->create();
$stage->require(['drupal/core:9.8.1']);
$stage->apply();
$stage->postApply();

Adam G-H
committed
$stage->destroy();
// If we did not get an exception, ensure we didn't expect any results.
$this->assertEmpty($expected_results);

Adam G-H
committed
}
catch (TestStageValidationException $e) {
$this->assertNotEmpty($expected_results);

Adam G-H
committed
$this->assertValidationResultsEqual($expected_results, $e->getResults());
// TestStage::dispatch() throws TestStageValidationException with the
// event object so that we can analyze it.
$this->assertNotEmpty($event_class);
$this->assertInstanceOf(StageValidationException::class, $e->getOriginalException());
$this->assertInstanceOf($event_class, $e->getEvent());

Adam G-H
committed
}
return $stage;

Adam G-H
committed
}
/**
* Asserts validation results are returned from the status check event.
*
* @param \Drupal\package_manager\ValidationResult[] $expected_results
* The expected validation results.
* @param \Drupal\Tests\package_manager\Kernel\TestStage|null $stage
* (optional) The test stage to use to create the status check event. If
* none is provided a new stage will be created.

Narendra Singh Rathore
committed
protected function assertStatusCheckResults(array $expected_results, Stage $stage = NULL): void {
$actual_results = $this->runStatusCheck($stage ?? $this->createStage(), $this->container->get('event_dispatcher'));
$this->assertValidationResultsEqual($expected_results, $actual_results);

Ted Bowman
committed
/**
* Marks all pending post-update functions as completed.
*
* Since kernel tests don't normally install modules and register their
* updates, this method makes sure that we are testing from a clean, fully
* up-to-date state.
*/
protected function registerPostUpdateFunctions(): void {
$updates = $this->container->get('update.post_update_registry')
->getPendingUpdateFunctions();
$this->container->get('keyvalue')
->get('post_update')
->set('existing_updates', $updates);
}
/**
* Creates a test project in a virtual file system.
*
* This will create two directories at the root of the virtual file system:
* 'active', which is the active directory containing a fake Drupal code base,
* and 'stage', which is the root directory used to stage changes. The path
* locator service will also be mocked so that it points to the test project.
*
* @param string|null $source_dir
* (optional) The path of a directory which should be copied into the
* virtual file system and used as the active directory.
protected function createVirtualProject(?string $source_dir = NULL): void {
$source_dir = $source_dir ?? __DIR__ . '/../../fixtures/fake_site';
// Create the active directory and copy its contents from a fixture.
$active_dir = vfsStream::newDirectory('active');
$this->vfsRoot->addChild($active_dir);
$active_dir = $active_dir->url();
// Move vfs://root/sites to vfs://root/active/sites.
$sites_in_vfs = vfsStream::url('root/sites');
rename($sites_in_vfs, $sites_in_vfs . '/active');
static::copyFixtureFilesTo($source_dir, $active_dir);
// Override siteDirectory to point to root/active/... instead of root/... .
$test_site_path = str_replace('vfs://root/', '', $this->siteDirectory);
$this->siteDirectory = vfsStream::url('root/active/' . $test_site_path);
// Override KernelTestBase::setUpFilesystem's Settings object.
$settings = Settings::getInstance() ? Settings::getAll() : [];
$settings['file_public_path'] = $this->siteDirectory . '/files';
$settings['config_sync_directory'] = $this->siteDirectory . '/files/config/sync';
new Settings($settings);

omkar podey
committed
// Create a stage root directory alongside the active directory.
$stage_dir = vfsStream::newDirectory('stage');
$this->vfsRoot->addChild($stage_dir);
// Ensure the path locator points to the virtual active directory. We assume
// that is its own web root and that the vendor directory is at its top
// level.
/** @var \Drupal\package_manager_bypass\PathLocator $path_locator */
$path_locator = $this->container->get('package_manager.path_locator');
$path_locator->setPaths($active_dir, $active_dir . '/vendor', '', $stage_dir->url());

Adam G-H
committed

omkar podey
committed
// Ensure the active directory will be copied into the virtual stage
// directory.

Adam G-H
committed
Beginner::setFixturePath($active_dir);
// Since the path locator now points to a virtual file system, we need to
// replace the disk space validator with a test-only version that bypasses
// system calls, like disk_free_space() and stat(), which aren't supported

Adam G-H
committed
// by vfsStream. This validator will persist through container rebuilds.
// @see ::register()
$validator = new TestDiskSpaceValidator(
$this->container->get('string_translation')
);
// By default, the validator should report that the root, vendor, and
// temporary directories have basically infinite free space.
$validator->freeSpace = [
$path_locator->getProjectRoot() => PHP_INT_MAX,
$path_locator->getVendorDirectory() => PHP_INT_MAX,
$validator->temporaryDirectory() => PHP_INT_MAX,
];
$this->container->set('package_manager.validator.disk_space', $validator);
}

Kunal Sachdev
committed
/**
* Copies a fixture directory into the active directory.

Kunal Sachdev
committed
*
* @param string $active_fixture_dir
* Path to fixture active directory from which the files will be copied.
*/
protected function copyFixtureFolderToActiveDirectory(string $active_fixture_dir) {

Kunal Sachdev
committed
$active_dir = $this->container->get('package_manager.path_locator')
->getProjectRoot();
static::copyFixtureFilesTo($active_fixture_dir, $active_dir);
}

Kunal Sachdev
committed
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
/**
* Sets the current (running) version of core, as known to the Update module.
*
* @param string $version
* The current version of core.
*/
protected function setCoreVersion(string $version): void {
$this->config('update_test.settings')
->set('system_info.#all.version', $version)
->save();
}
/**
* Sets the release metadata file to use when fetching available updates.
*
* @param string[] $files
* The paths of the XML metadata files to use, keyed by project name.
*/
protected function setReleaseMetadata(array $files): void {
$responses = [];
foreach ($files as $project => $file) {
$metadata = Utils::tryFopen($file, 'r');
$responses["/release-history/$project/current"] = new Response(200, [], Utils::streamFor($metadata));
}
$callable = function (RequestInterface $request) use ($responses): Response {
return $responses[$request->getUri()->getPath()] ?? new Response(404);
};
// The mock handler's queue consist of same callable as many times as the
// number of requests we expect to be made for update XML because it will
// retrieve one item off the queue for each request.
// @see \GuzzleHttp\Handler\MockHandler::__invoke()
$handler = new MockHandler(array_fill(0, 100, $callable));
$this->client = new Client([
'handler' => HandlerStack::create($handler),
]);
$this->container->set('http_client', $this->client);
}

Kunal Sachdev
committed
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
/**
* Adds an event listener on an event for testing purposes.
*
* @param callable $listener
* The listener to add.
* @param string $event_class
* (optional) The event to listen to. Defaults to PreApplyEvent.
* @param int $priority
* (optional) The priority. Defaults to PHP_INT_MAX.
*/
protected function addEventTestListener(callable $listener, string $event_class = PreApplyEvent::class, int $priority = PHP_INT_MAX): void {
$this->container->get('event_dispatcher')
->addListener($event_class, $listener, $priority);
}
/**
* Asserts event propagation is stopped by a certain event subscriber.
*
* @param string $event_class
* The event during which propagation is expected to stop.
* @param callable $expected_propagation_stopper
* The event subscriber (which subscribes to the given event class) which is
* expected to stop propagation. This event subscriber must have been
* registered by one of the installed Drupal module.
*/
protected function assertEventPropagationStopped(string $event_class, callable $expected_propagation_stopper): void {
$priority = $this->container->get('event_dispatcher')->getListenerPriority($event_class, $expected_propagation_stopper);
// Ensure the event subscriber was actually a listener for the event.
$this->assertIsInt($priority);
// Add a listener with a priority that is 1 less than priority of the
// event subscriber. This listener would be called after
// $expected_propagation_stopper if the event propagation was not stopped
// and cause the test to fail.
$this->addEventTestListener(function () use ($event_class): void {
$this->fail('Event propagation should have been stopped during ' . $event_class . '.');
}, $event_class, $priority - 1);
}

Adam G-H
committed
}
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
/**
* Test-only class to associate event with StageValidationException.
*
* @todo Remove this class in https://drupal.org/i/3331355 or if that issue is
* closed without adding the ability to associate events with exceptions
* remove this comment.
*/
final class TestStageValidationException extends StageValidationException {
/**
* The stage event.
*
* @var \Drupal\package_manager\Event\StageEvent
*/
private $event;
/**
* The original exception.
*
* @var \Drupal\package_manager\Exception\StageValidationException
*/
private $originalException;
public function __construct(StageValidationException $original_exception, StageEvent $event) {
parent::__construct($original_exception->getResults(), $original_exception->getMessage(), $original_exception->getCode(), $original_exception);
$this->originalException = $original_exception;
$this->event = $event;
}
/**
* Gets the original exception which is triggered at the event.
*
* @return \Drupal\package_manager\Exception\StageValidationException
* Exception triggered at event.
*/
public function getOriginalException(): StageValidationException {
return $this->originalException;
}
/**
* Gets the stage event which triggers the exception.
*
* @return \Drupal\package_manager\Event\StageEvent
* Event triggering stage exception.
*/
public function getEvent(): StageEvent {
return $this->event;
}
}

Adam G-H
committed
/**
* Common functions for test stages.

Adam G-H
committed
*/
trait TestStageTrait {

Adam G-H
committed
/**
* {@inheritdoc}
*/

Adam G-H
committed
protected function dispatch(StageEvent $event, callable $on_error = NULL): void {

Adam G-H
committed
try {

Adam G-H
committed
parent::dispatch($event, $on_error);

Adam G-H
committed
}
catch (StageValidationException $e) {
// Throw TestStageValidationException with event object so that test
// code can verify that the exception was thrown when a specific event was
// dispatched.
throw new TestStageValidationException($e, $event);

Adam G-H
committed
}
}
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
/**
* Defines a path value object that is aware of the virtual file system.
*/
class TestPath extends AbstractPath {
/**
* {@inheritdoc}
*/
protected function doResolve(string $basePath): string {
if (str_starts_with($this->path, vfsStream::SCHEME . '://')) {
return $this->path;
}
return implode(DIRECTORY_SEPARATOR, [$basePath, $this->path]);
}
}
/**
* Defines a path factory that is aware of the virtual file system.
*/
class TestPathFactory implements PathFactoryInterface {
/**
* {@inheritdoc}
*/
public static function create(string $path): PathInterface {
return new TestPath($path);
}
}
/**
* Defines a stage specifically for testing purposes.
*/
class TestStage extends Stage {
use TestStageTrait;
/**
* {@inheritdoc}
*
* TRICKY: without this, any failed ::assertStatusCheckResults()
* will fail, because PHPUnit will want to serialize all arguments in the call
* stack.
*
* @see https://www.drupal.org/project/automatic_updates/issues/3312619#comment-14801308
*/
public function __sleep(): array {
return [];
}

Adam G-H
committed
}
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
/**
* A test version of the disk space validator to bypass system-level functions.
*/
class TestDiskSpaceValidator extends DiskSpaceValidator {
/**
* Whether the root and vendor directories are on the same logical disk.
*
* @var bool
*/
public $sharedDisk = TRUE;
/**
* The amount of free space, keyed by path.
*
* @var float[]
*/
public $freeSpace = [];
/**
* {@inheritdoc}
*/
protected function stat(string $path): array {
return [
'dev' => $this->sharedDisk ? 'disk' : uniqid(),
];
}
/**
* {@inheritdoc}
*/
protected function freeSpace(string $path): float {
return $this->freeSpace[$path];
}
/**
* {@inheritdoc}
*/
public function temporaryDirectory(): string {
return 'temp';
}
}