diff --git a/config/behat-dusk.php b/config/behat-dusk.php index 3ac44ad..0b1b7d6 100644 --- a/config/behat-dusk.php +++ b/config/behat-dusk.php @@ -1,5 +1,17 @@ base_path('tests/Behat'), + + 'feature_path' => base_path('tests/Behat/Features'), + + 'context_path' => base_path('tests/Behat/Context'), + + 'screenshot_path' => base_path('tests/Behat/artifacts/screenshots'), + + 'console_log_path' => base_path('tests/Behat/artifacts/console'), + + 'file_path' => base_path('public'), + ]; diff --git a/src/Console/BehatCommand.php b/src/Console/BehatCommand.php new file mode 100644 index 0000000..a976da5 --- /dev/null +++ b/src/Console/BehatCommand.php @@ -0,0 +1,358 @@ +ignoreValidationErrors(); + } + + /** + * Execute the console command. + * + * @return mixed + */ + public function handle() + { + $this->purgeScreenshots(); + + $this->purgeConsoleLogs(); + + $this->setupBehatEnvironment(); + + $this->startHttpServer(function () { + $behatProcess = (new Process(array_merge( + $this->binary(), $this->behatArguments($_SERVER['argv']) + )))->setTimeout(null)->setTty(true); + + $behatProcess->start(); + $behatProcess->wait(); + $this->killChildsProcess($behatProcess->getPid(), 15, false); + $behatProcess->stop(); + + $this->teardownBehatEnviroment(); + }); + + return true; + } + + /** + * Build a process to run Laravel Http Server. + * + * @param \Closure $callback + * @return void + */ + protected function startHttpServer(Closure $callback) + { + $arguments = [ + PHP_BINARY, + 'artisan', + 'serve', + $this->input->getOption('host') ? '--host=' . $this->input->getOption('host') : null, + $this->input->getOption('port') ? '--port=' . $this->input->getOption('port') : null, + '--tries=0', + ]; + + $process = (new Process(array_filter($arguments))) + ->setTimeout(null); + + try { + $process->start(); + + $process->waitUntil(function () use ($callback) { + $this->output->writeln(sprintf( + "Laravel HTTP Server started: http://%s:%s", + $this->input->getOption('host') ?? '127.0.0.1', + $this->input->getOption('port') ?? Env::get('SERVER_PORT', 8000) + )); + $this->output->newLine(); + + $callback(); + + return true; + }); + } catch (ProcessSignaledException $e) { + $this->killChildsProcess($process->getPid(), 15, false); + $process->stop(); + + if (extension_loaded('pcntl') && $e->getSignal() !== SIGINT) { + throw $e; + } + } + + $this->killChildsProcess($process->getPid(), 15, false); + $process->stop(); + + $this->output->writeln("\nShutdown Laravel HTTP Server."); + } + + /** + * Get the console command options. + * + * @return array + */ + protected function getOptions() + { + return [ + ['host', null, InputOption::VALUE_OPTIONAL, 'The host address to serve the application on'], + + ['port', null, InputOption::VALUE_OPTIONAL, 'The port to serve the application on'], + ]; + } + + /** + * Get the Behat binary to execute. + * + * @return array + */ + protected function binary() + { + if ('phpdbg' === PHP_SAPI) { + return [PHP_BINARY, '-qrr', 'vendor/bin/behat']; + } + + return [PHP_BINARY, 'vendor/bin/behat']; + } + + /** + * Get the array of arguments for running Behat. + * + * @param array $options + * @return array + */ + protected function behatArguments($options) + { + $options = array_slice($options, 2); + + $options = array_values(array_filter($options, function ($option) { + return ! Str::startsWith($option, [ + '--env=', + '--host', + '--port', + ]); + })); + + return $options; + } + + /** + * Purge the failure screenshots. + * + * @return void + */ + protected function purgeScreenshots() + { + $path = config('behat-dusk.screenshot_path'); + + if (! is_dir($path)) { + return; + } + + $files = Finder::create()->files() + ->in($path) + ->name('failure-*'); + + foreach ($files as $file) { + @unlink($file->getRealPath()); + } + } + + /** + * Purge the console logs. + * + * @return void + */ + protected function purgeConsoleLogs() + { + $path = config('behat-dusk.console_log_path'); + + if (! is_dir($path)) { + return; + } + + $files = Finder::create()->files() + ->in($path) + ->name('*.log'); + + foreach ($files as $file) { + @unlink($file->getRealPath()); + } + } + + /** + * Setup the Behat environment. + * + * @return void + */ + protected function setupBehatEnvironment() + { + if (file_exists(base_path($this->behatEnvironmentFile()))) { + if (file_get_contents(base_path('.env')) !== file_get_contents(base_path($this->behatEnvironmentFile()))) { + $this->backupEnvironment(); + } + + $this->refreshEnvironment(); + } + + $this->setupSignalHandler(); + } + + /** + * Backup the current environment file. + * + * @return void + */ + protected function backupEnvironment() + { + copy(base_path('.env'), base_path('.env-behat.backup')); + + copy(base_path($this->behatEnvironmentFile()), base_path('.env')); + } + + /** + * Refresh the current environment variables. + * + * @return void + */ + protected function refreshEnvironment() + { + if (! method_exists(Dotenv::class, 'create')) { + (new Dotenv(base_path()))->overload(); + + return; + } + + Dotenv::create(base_path())->overload(); + } + + /** + * Setup the SIGINT signal handler for CTRL+C exits. + * + * @return void + */ + protected function setupSignalHandler() + { + if (extension_loaded('pcntl')) { + pcntl_async_signals(true); + + pcntl_signal(SIGINT, function () { + $this->teardownBehatEnviroment(); + }); + } + } + + /** + * Restore the original environment. + * + * @return void + */ + protected function teardownBehatEnviroment() + { + if (file_exists(base_path($this->behatEnvironmentFile())) && file_exists(base_path('.env-behat.backup'))) { + $this->restoreEnvironment(); + } + } + + /** + * Restore the backed-up environment file. + * + * @return void + */ + protected function restoreEnvironment() + { + copy(base_path('.env-behat.backup'), base_path('.env')); + + unlink(base_path('.env-behat.backup')); + } + + /** + * Get the name of the Behat file for the environment. + * + * @return string + */ + protected function behatEnvironmentFile() + { + return '.env.behat'; + } + + /** + * Kill childs process created by Symfony Process. + * + * @see https://github.com/symfony/symfony/issues/34406 + * + * @param int $pid + * @param int $signal + * @param bool $throwException + * @return bool + */ + protected function killChildsProcess($pid, int $signal, bool $throwException): bool + { + $ret = proc_open(sprintf('pgrep -P %d', $pid), [1 => ['pipe', 'w']], $pipes); + + if ($ret && $output = fgets($pipes[1])) { + proc_close($ret); + $pids = explode("\n", $output); + + foreach ($pids as $childPid) { + if (empty($childPid)) { + continue; + } + + $childPid = (int) $childPid; + if (\function_exists('posix_kill')) { + $ok = @posix_kill($childPid, $signal); + } elseif ($ok = proc_open(sprintf('kill -%d %d', $signal, $childPid), [2 => ['pipe', 'w']], $pipes)) { + $resource = $ok; + $ok = false === fgets($pipes[2]); + proc_close($resource); + } + + if (!$ok) { + if ($throwException) { + throw new RuntimeException(sprintf('Error while sending signal "%s" to child.', $signal)); + } + + return false; + } + } + + return true; + } + + return false; + } +} diff --git a/src/Console/InstallCommand.php b/src/Console/InstallCommand.php new file mode 100644 index 0000000..5a696b5 --- /dev/null +++ b/src/Console/InstallCommand.php @@ -0,0 +1,106 @@ +call('dusk:install'); + + if (! is_dir(config('behat-dusk.base_path'))) { + mkdir(config('behat-dusk.base_path'), 0755, true); + } + + if (! is_dir(config('behat-dusk.context_path'))) { + mkdir(config('behat-dusk.context_path'), 0755, true); + } + + $this->createFeaturesDirectory(); + + $this->createScreenshotsDirectory(); + + $this->createConsoleDirectory(); + + $stubs = [ + 'FeatureContext.stub' => config('behat-dusk.context_path') . DIRECTORY_SEPARATOR . 'FeatureContext.php', + 'BehatDuskTestCase.stub' => config('behat-dusk.base_path') . DIRECTORY_SEPARATOR . 'BehatDuskTestCase.php', + 'behat.yml.stub' => base_path('behat.yml'), + ]; + + foreach ($stubs as $stub => $file) { + if (! is_file($file)) { + copy(__DIR__.'/../../stubs/'.$stub, $file); + } + } + + $this->info('LaravelBehatDusk scaffolding installed successfully.'); + } + + /** + * Create the features directory. + * + * @return void + */ + protected function createFeaturesDirectory() + { + if (! is_dir(config('behat-dusk.feature_path'))) { + mkdir(config('behat-dusk.feature_path'), 0755, true); + + file_put_contents(config('behat-dusk.feature_path') . DIRECTORY_SEPARATOR . '.gitkeep', ''); + } + } + + /** + * Create the screenshots directory. + * + * @return void + */ + protected function createScreenshotsDirectory() + { + if (! is_dir(config('behat-dusk.screenshot_path'))) { + mkdir(config('behat-dusk.screenshot_path'), 0755, true); + + file_put_contents(config('behat-dusk.screenshot_path') . DIRECTORY_SEPARATOR . '.gitignore', '* +!.gitignore +'); + } + } + + /** + * Create the console directory. + * + * @return void + */ + protected function createConsoleDirectory() + { + if (! is_dir(config('behat-dusk.console_log_path'))) { + mkdir(config('behat-dusk.console_log_path'), 0755, true); + + file_put_contents(config('behat-dusk.console_log_path') . DIRECTORY_SEPARATOR . '.gitignore', '* +!.gitignore +'); + } + } +} diff --git a/src/DuskContext.php b/src/DuskContext.php new file mode 100644 index 0000000..a434d08 --- /dev/null +++ b/src/DuskContext.php @@ -0,0 +1,213 @@ +[^"]*)" element in source$/ + */ + public function assertElementOnPage($element) + { + $this->browse(function (Browser $browser) use ($element) { + $browser->assertPresent($element); + }); + } + + /** + * Checks, that element with specified selector doesn't exist on page source. + * + * Example: Then I should not see a "canvas" element in source + * Example: And I should not see a "canvas" element in source + * + * @Then /^(?:|I )should not see an? "(?P[^"]*)" element in source$/ + */ + public function assertElementNotOnPage($selector) + { + $this->browse(function (Browser $browser) use ($selector) { + $fullSelector = $this->resolver->format($selector); + + PHPUnit::assertFalse( + ! is_null($this->resolver->find($selector)), + "Element [{$fullSelector}] is not present." + ); + + return $this; + }); + } + + /** + * Checks, that element with specified selector is visible. + * + * Example: Then I should see a "canvas" element + * Example: And I should see a "canvas" element + * + * @Then /^(?:|I )should see a visible "(?P[^"]*)" element$/ + */ + public function assertElementVisible($selector) + { + $this->browse(function (Browser $browser) use ($selector) { + $browser->assertVisible($selector); + }); + } + + /** + * Checks, that element with specified selector is invisible. + * + * Example: Then I should not see a "canvas" element + * Example: And I should not see a "canvas" element + * + * @Then /^(?:|I )should see an invisible "(?P[^"]*)" element$/ + */ + public function assertElementInvisible($selector) + { + $this->browse(function (Browser $browser) use ($selector) { + $fullSelector = $this->resolver->format($selector); + + try { + $invisible = ! $this->resolver->findOrFail($selector)->isDisplayed(); + } catch (NoSuchElementException $e) { + $invisible = true; + } + + PHPUnit::assertTrue($invisible, "Element [{$fullSelector}] is visible."); + + return $this; + }); + } + + /** + * Fills in form field with specified selector. + * + * Example: When I fill in "username" with: "bwayne" + * Example: And I fill in "bwayne" for "username" + * + * @When /^(?:|I )fill in "(?P(?:[^"]|\\")*)" with "(?P(?:[^"]|\\")*)"$/ + * @When /^(?:|I )fill in "(?P(?:[^"]|\\")*)" with:$/ + * @When /^(?:|I )fill in "(?P(?:[^"]|\\")*)" for "(?P(?:[^"]|\\")*)"$/ + */ + public function fillField($selector, $value) + { + $this->browse(function (Browser $browser) use ($selector, $value) { + $browser->type($selector, $value); + }); + } + + /** + * Selects value in select field with specified selector. + * + * Example: When I select "Bats" from "user_fears" + * Example: And I select "Bats" from "user_fears" + * + * @When /^(?:|I )select "(?P(?:[^"]|\\")*)" from "(?P(?:[^"]|\\")*)"$/ + */ + public function selectOption($selector, $value) + { + $this->browse(function (Browser $browser) use ($selector, $value) { + $browser->select($selector, $value); + }); + } + + /** + * Selects value in radio field with specified selector. + * + * Example: When I select radio "Bats" from "user_fears" + * Example: And I select radio "Bats" from "user_fears" + * + * @When /^(?:|I )select radio "(?P(?:[^"]|\\")*)" from "(?P(?:[^"]|\\")*)"$/ + */ + public function selectRadio($selector, $value) + { + $this->browse(function (Browser $browser) use ($selector, $value) { + $browser->radio($selector, $value); + }); + } + + /** + * Attaches file to field with specified selector. + * + * Example: When I attach "bwayne_profile.png" to "profileImageUpload" + * Example: And I attach "bwayne_profile.png" to "profileImageUpload" + * + * @When /^(?:|I )attach the file "(?P[^"]*)" to "(?P(?:[^"]|\\")*)"$/ + */ + public function attachFileToField($selector, $path) + { + $this->browse(function (Browser $browser) use ($selector, $path) { + $browser->attach( + $selector, + $this->fileBasePath . (Str::startsWith($path, '/') ? $path : DIRECTORY_SEPARATOR . $path)); + }); + } + + /** + * Presses button with specified selector. + * + * Example: When I press "Log In" + * Example: And I press "Log In" + * + * @When /^(?:|I )press "(?P(?:[^"]|\\")*)"$/ + */ + public function pressButton($selector) + { + $this->browse(function (Browser $browser) use ($selector) { + $browser->press($selector); + }); + } + + /** + * Checks, that current page PATH is equal to specified. + * + * Example: Then I should be on "/" + * Example: And I should be on "/bats" + * Example: And I should be on "http://google.com" + * + * @Then /^(?:|I )should be on "(?P[^"]+)"$/ + */ + public function assertPageAddress($path) + { + $this->browse(function (Browser $browser) use ($path) { + $browser->assertPathIs($path); + }); + } + + /** + * Checks, that element with specified selector contains specified text/ + * + * Example: Then I should see "Batman" in the "heroes_list" element + * Example: And I should see "Batman" in the "heroes_list" element + * + * @Then /^(?:|I )should see "(?P(?:[^"]|\\")*)" in the "(?P[^"]*)" element$/ + */ + public function assertElementContainsText($selector, $text) + { + $this->browse(function (Browser $browser) use ($selector, $text) { + $browser->assertSeeIn($selector, $text); + }); + } + + /** + * Checks, that element with specified selector doesn't contain specified text. + * + * Example: Then I should not see "Bruce Wayne" in the "heroes_alter_egos" element + * Example: And I should not see "Bruce Wayne" in the "heroes_alter_egos" element + * + * @Then /^(?:|I )should not see "(?P(?:[^"]|\\")*)" in the "(?P[^"]*)" element$/ + */ + public function assertElementNotContainsText($selector, $text) + { + $this->browse(function (Browser $browser) use ($selector, $text) { + $browser->assertDontSeeIn($selector, $text); + }); + } +} diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php index e59a86a..8ca8a9f 100644 --- a/src/ServiceProvider.php +++ b/src/ServiceProvider.php @@ -2,8 +2,7 @@ namespace Nmflabs\LaravelBehatDusk; -use Laravel\Lumen\Application as LumenApplication; -use Illuminate\Foundation\Application as LaravelApplication; +use Nmflabs\LaravelBehatDusk\Console; use Illuminate\Support\ServiceProvider as BaseServiceProvider; class ServiceProvider extends BaseServiceProvider @@ -17,11 +16,7 @@ public function boot() { $source = realpath(__DIR__.'/../config/behat-dusk.php'); - if ($this->app instanceof LaravelApplication) { - $this->publishes([$source => config_path('behat-dusk.php')]); - } elseif ($this->app instanceof LumenApplication) { - $this->app->configure('behat-dusk'); - } + $this->publishes([$source => config_path('behat-dusk.php')]); $this->mergeConfigFrom($source, 'behat-dusk'); } @@ -33,6 +28,11 @@ public function boot() */ public function register() { - // + if ($this->app->runningInConsole()) { + $this->commands([ + Console\BehatCommand::class, + Console\InstallCommand::class, + ]); + } } } diff --git a/stubs/BehatDuskTestCase.stub b/stubs/BehatDuskTestCase.stub new file mode 100644 index 0000000..9b1e422 --- /dev/null +++ b/stubs/BehatDuskTestCase.stub @@ -0,0 +1,47 @@ +setUp(); + } + + /** + * {@inheritdoc} + */ + protected function setUp(): void + { + parent::setUp(); + + Browser::$storeScreenshotsAt = config('behat-dusk.screenshot_path'); + + Browser::$storeConsoleLogAt = config('behat-dusk.console_log_path'); + + $this->fileBasePath = config('behat-dusk.file_path'); + + static::startChromeDriver(); + } +} diff --git a/stubs/FeatureContext.stub b/stubs/FeatureContext.stub new file mode 100644 index 0000000..eb04d70 --- /dev/null +++ b/stubs/FeatureContext.stub @@ -0,0 +1,15 @@ +