diff --git a/.gitignore b/.gitignore index 1b10983..a294749 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .idea/ composer.lock vendor/ -core/ \ No newline at end of file +core/ +modules/ \ No newline at end of file diff --git a/README.md b/README.md index a706d18..dfb928f 100644 --- a/README.md +++ b/README.md @@ -9,11 +9,17 @@ Includes: - Drupal Drush - Drupal Acceptance +## Differences from guncha25/drupal-codeception + +- PHP 8 support +- No dependency on Faker +- Various fixes + ## Installation Require package: -```composer require guncha25/drupal-codeception --dev``` +```composer require coldfrontlabs/drupal-codeception --dev``` If codeception was not previously set up: diff --git a/composer.json b/composer.json index 001a5cc..fb39e24 100644 --- a/composer.json +++ b/composer.json @@ -1,5 +1,5 @@ { - "name": "guncha25/drupal-codeception", + "name": "coldfrontlabs/drupal-codeception", "description": "Codeception toolset for Drupal testing.", "type": "package", "repositories": [ @@ -9,17 +9,20 @@ } ], "require": { - "codeception/codeception": "^4.0", - "fzaninotto/faker": "^1.8", - "codeception/module-webdriver": "^1.1", - "webflo/drupal-finder": "^1.2" + "codeception/codeception": "^4.0 || ^5.0", + "codeception/module-webdriver": "*", + "webflo/drupal-finder": "*" }, "require-dev": { - "composer/installers": "^1", - "drupal/core": "^8" + "composer/installers": "*", + "drupal/core": "^8 || ^9 || ^10" }, "license": "GPL-2.0", "authors": [ + { + "name": "Mathew Winstone", + "email": "mwinstone@coldfrontlabs.ca" + }, { "name": "Guntis Jakovins", "email": "guntis.jakovins@gmail.com" @@ -30,5 +33,10 @@ "psr-4": { "Codeception\\": "src/Codeception" } + }, + "config": { + "allow-plugins": { + "composer/installers": true + } } } diff --git a/src/Codeception/Module/DrupalBootstrap.php b/src/Codeception/Module/DrupalBootstrap.php index 8a93e05..e7e81cb 100644 --- a/src/Codeception/Module/DrupalBootstrap.php +++ b/src/Codeception/Module/DrupalBootstrap.php @@ -8,7 +8,7 @@ use Codeception\TestDrupalKernel; use Symfony\Component\HttpFoundation\Request; use DrupalFinder\DrupalFinder; - +use Codeception\Module\DrupalBootstrap\EventsAssertionsTrait; /** * Class DrupalBootstrap. @@ -25,6 +25,8 @@ */ class DrupalBootstrap extends Module { + use EventsAssertionsTrait; + /** * Default module configuration. * @@ -34,6 +36,13 @@ class DrupalBootstrap extends Module { 'site_path' => 'sites/default', ]; + /** + * Track wether we enabled the webprofiler module or not. + * + * @var bool + */ + protected $enabledWebProfiler = FALSE; + /** * DrupalBootstrap constructor. * @@ -72,4 +81,25 @@ public function __construct(ModuleContainer $container, $config = NULL) { $kernel->bootTestEnvironment($this->_getConfig('site_path'), $request); } + /** + * Enabled dependent modules. + */ + public function _beforeSuite($settings = []) { + $module_handler = \Drupal::service('module_handler'); + if (!$module_handler->moduleExists('webprofiler')) { + $this->enabledWebProfiler = TRUE; + \Drupal::service('module_installer')->install(['webprofiler']); + } + } + + /** + * Disable modules which were enabled. + */ + public function _afterSuite($settings = []) { + if ($this->enabledWebProfiler) { + $this->enabledWebProfiler = FALSE; + \Drupal::service('module_installer')->uninstall(['webprofiler']); + } + } + } diff --git a/src/Codeception/Module/DrupalBootstrap/EventsAssertionsTrait.php b/src/Codeception/Module/DrupalBootstrap/EventsAssertionsTrait.php new file mode 100644 index 0000000..b8f6ad2 --- /dev/null +++ b/src/Codeception/Module/DrupalBootstrap/EventsAssertionsTrait.php @@ -0,0 +1,183 @@ +dontSeeOrphanEvent(); + * $I->dontSeeOrphanEvent('App\MyEvent'); + * $I->dontSeeOrphanEvent(new App\Events\MyEvent()); + * $I->dontSeeOrphanEvent(['App\MyEvent', 'App\MyOtherEvent']); + * ``` + * + * @param string|object|string[] $expected + */ + public function dontSeeOrphanEvent($expected = NULL): void { + $eventCollector = $this->grabEventCollector(); + + $data = $eventCollector->getOrphanedEvents(); + $expected = is_array($expected) ? $expected : [$expected]; + + if ($expected === NULL) { + $this->assertSame(0, count($data)); + } + else { + $this->assertEventNotTriggered($data, $expected); + } + } + + /** + * Verifies that one or more event listeners were not called during the test. + * + * ```php + * dontSeeEventTriggered('App\MyEvent'); + * $I->dontSeeEventTriggered(new App\Events\MyEvent()); + * $I->dontSeeEventTriggered(['App\MyEvent', 'App\MyOtherEvent']); + * $I->dontSeeEventTriggered('my_event_string_name'); + * $I->dontSeeEventTriggered(['my_event_string', 'my_other_event_string]); + * ``` + * + * @param string|object|string[] $expected + */ + public function dontSeeEventTriggered($expected): void { + $eventCollector = $this->grabEventCollector(); + + $data = $eventCollector->getCalledListeners(); + $expected = is_array($expected) ? $expected : [$expected]; + + $this->assertEventNotTriggered($data, $expected); + } + + /** + * Verifies that one or more orphan events were dispatched during the test. + * + * An orphan event is an event that was triggered by manually executing the + * [`dispatch()`](https://symfony.com/doc/current/components/event_dispatcher.html#dispatch-the-event) method + * of the EventDispatcher but was not handled by any listener after it was dispatched. + * + * ```php + * seeOrphanEvent('App\MyEvent'); + * $I->seeOrphanEvent(new App\Events\MyEvent()); + * $I->seeOrphanEvent(['App\MyEvent', 'App\MyOtherEvent']); + * $I->seeOrphanEvent('my_event_string_name'); + * $I->seeOrphanEvent(['my_event_string_name', 'my_other_event_string]); + * ``` + * + * @param string|object|string[] $expected + */ + public function seeOrphanEvent($expected): void { + $eventCollector = $this->grabEventCollector(); + + $data = $eventCollector->getOrphanedEvents(); + $expected = is_array($expected) ? $expected : [$expected]; + + $this->assertEventTriggered($data, $expected); + } + + /** + * Verifies that one or more event listeners were called during the test. + * + * ```php + * seeEventTriggered('App\MyEvent'); + * $I->seeEventTriggered(new App\Events\MyEvent()); + * $I->seeEventTriggered(['App\MyEvent', 'App\MyOtherEvent']); + * $I->seeEventTriggered('my_event_string_name'); + * $I->seeEventTriggered(['my_event_string_name', 'my_other_event_string]); + * ``` + * + * @param string|object|string[] $expected + */ + public function seeEventTriggered($expected): void { + $eventCollector = $this->grabEventCollector(); + + $data = $eventCollector->getCalledListeners(); + $expected = is_array($expected) ? $expected : [$expected]; + + $this->assertEventTriggered($data, $expected); + } + + /** + * + */ + protected function assertEventNotTriggered(array $data, array $expected): void { + foreach ($expected as $expectedEvent) { + $expectedEvent = is_object($expectedEvent) ? get_class($expectedEvent) : $expectedEvent; + $this->assertFalse( + $this->eventWasTriggered($data, (string) $expectedEvent), + "The '{$expectedEvent}' event triggered" + ); + } + } + + /** + * + */ + protected function assertEventTriggered(array $data, array $expected): void { + if (count($data) === 0) { + $this->fail('No event was triggered'); + } + + foreach ($expected as $expectedEvent) { + $expectedEvent = is_object($expectedEvent) ? get_class($expectedEvent) : $expectedEvent; + $this->assertTrue( + $this->eventWasTriggered($data, (string) $expectedEvent), + "The '{$expectedEvent}' event did not trigger" + ); + } + } + + /** + * + */ + protected function eventWasTriggered(array $actual, string $expectedEvent): bool { + $triggered = FALSE; + + foreach ($actual as $name => $actualEvent) { + // Called Listeners. + if ($name === $expectedEvent && !empty($actualEvent)) { + $triggered = TRUE; + } + } + + return $triggered; + } + + /** + * Get the event data collector service. + */ + protected function grabEventCollector(): EventsDataCollector { + $event_dispatcher = \Drupal::service('event_dispatcher'); + if ($event_dispatcher instanceof EventDispatcherTraceableInterface) { + $collector = new EventsDataCollector($event_dispatcher); + $collector->lateCollect(); + return $collector; + } + else { + throw new \Exception('Webprofiler module is required for testing events.'); + } + } + +} diff --git a/src/Codeception/Module/DrupalGroup.php b/src/Codeception/Module/DrupalGroup.php new file mode 100644 index 0000000..05eb5db --- /dev/null +++ b/src/Codeception/Module/DrupalGroup.php @@ -0,0 +1,106 @@ +getStorage($type) + ->create($values); + if ($validate && $entity instanceof FieldableEntityInterface) { + $violations = $entity->validate(); + if ($violations->count() > 0) { + $message = PHP_EOL; + foreach ($violations as $violation) { + $message .= $violation->getPropertyPath() . ': ' . $violation->getMessage() . PHP_EOL; + } + throw new \Exception($message); + } + } + // Group specific entity save options. + $entity->setOwner(User::load($values['uid'] ?? 1)); + $entity->save(); + } + catch (\Exception $e) { + $this->fail('Could not create group entity. Error message: ' . $e->getMessage()); + } + if (!empty($entity)) { + $this->registerTestEntity($entity->getEntityTypeId(), $entity->id()); + + return $entity; + } + + return FALSE; + } + + /** + * Join the defined group. + * + * @param \Drupal\group\Entity\GroupInterface $group + * Instance of a group. + * @param \Drupal\user\UserInterface $user + * A drupal user to add to the group. + * + * @return \Drupal\group\GroupMembership|false + * Returns the group content entity, FALSE otherwise. + */ + public function joinGroup(GroupInterface $group, UserInterface $user) { + $group->addMember($user); + return $group->getMember($user); + } + + /** + * Leave a group. + * + * @param \Drupal\group\Entity\GroupInterface $group + * Instance of a group. + * @param \Drupal\user\UserInterface $user + * A drupal user to add to the group. + * + * @return bool + * Returns the TRUE if the user is no longer a member of the group, + * FALSE otherwise. + */ + public function leaveGroup(GroupInterface $group, UserInterface $user) { + $group->removeMember($user); + // Get member should return FALSE if the user isn't a member so we + // reverse the logic. If they are still a member it'll cast to TRUE. + $is_member = (bool) $group->getMember($user); + return !$is_member; + } + +} diff --git a/src/Codeception/Module/DrupalUser.php b/src/Codeception/Module/DrupalUser.php index 8f6112a..a54c234 100644 --- a/src/Codeception/Module/DrupalUser.php +++ b/src/Codeception/Module/DrupalUser.php @@ -6,8 +6,8 @@ use Drupal\Core\Config\StorageInterface; use Drupal\Core\Entity\EntityStorageInterface; use Drupal\user\Entity\User; -use Faker\Factory; use Codeception\Util\Drush; +use Faker\Factory; /** * Class DrupalUser. @@ -17,8 +17,6 @@ * modules: * - DrupalUser: * default_role: 'authenticated' - * driver: 'PhpBrowser' - * drush: './vendor/bin/drush' * cleanup_entities: * - media * - file @@ -46,6 +44,13 @@ class DrupalUser extends Module { */ protected $users; + /** + * Flag to note whether the CLI should be used for user actions. + * + * @var bool + */ + protected $useCli = FALSE; + /** * Default module configuration. * @@ -53,9 +58,9 @@ class DrupalUser extends Module { */ protected $config = [ 'alias' => '', - 'default_role' => 'authenticated', - 'driver' => 'WebDriver', + 'driver' => NULL, 'drush' => 'drush', + 'default_role' => 'authenticated', 'cleanup_entities' => [], 'cleanup_test' => TRUE, 'cleanup_failed' => TRUE, @@ -68,10 +73,11 @@ class DrupalUser extends Module { public function _beforeSuite($settings = []) { // @codingStandardsIgnoreLine $this->driver = null; if (!$this->hasModule($this->_getConfig('driver'))) { - $this->fail('User driver module not found.'); + $this->useCli = TRUE; + } + else { + $this->driver = $this->getModule($this->_getConfig('driver')); } - - $this->driver = $this->getModule($this->_getConfig('driver')); } /** @@ -142,11 +148,30 @@ public function createUserWithRoles(array $roles = [], $password = FALSE) { * User id. */ public function logInAs($username) { - $alias = $this->_getConfig('alias') ? $this->_getConfig('alias') . ' ' : ''; - $output = Drush::runDrush($alias. 'uli --name=' . $username, $this->_getConfig('drush'), $this->_getConfig('working_directory')); - $gen_url = str_replace(PHP_EOL, '', $output); - $url = substr($gen_url, strpos($gen_url, '/user/reset')); - $this->driver->amOnPage($url); + /** @var \Drupal\user\Entity\User $user */ + try { + if ($this->useCli) { + // Load the user. + $account = user_load_by_name($username); + + if (FALSE === $account ) { + throw new \Exception(); + } + + // Login with the user. + user_login_finalize($account); + } + else { + $alias = $this->_getConfig('alias') ? $this->_getConfig('alias') . ' ' : ''; + $output = Drush::runDrush($alias. 'uli --name=' . $username, $this->_getConfig('drush'), $this->_getConfig('working_directory')); + $gen_url = str_replace(PHP_EOL, '', $output); + $url = substr($gen_url, strpos($gen_url, '/user/reset')); + $this->driver->amOnPage($url); + } + } + catch (\Exception $e) { + $this->fail('Coud not login with username ' . $username); + } } /**