From 3ac7e83dae3945b520cb884f3735d510a48247cd Mon Sep 17 00:00:00 2001 From: Ingo Schommer Date: Fri, 9 Nov 2012 17:34:24 +0100 Subject: [PATCH] ENHANCEMENT Migrated generic contexts from temporary 'behat-tests' module The CMS specific context classes will move to framework --- .../BehatExtension/Context/BasicContext.php | 332 +++++++++++++ .../BehatExtension/Context/LoginContext.php | 150 ++++++ .../Context/SilverStripeContext.php | 444 ++++++++++++++++++ 3 files changed, 926 insertions(+) create mode 100644 src/SilverStripe/BehatExtension/Context/BasicContext.php create mode 100644 src/SilverStripe/BehatExtension/Context/LoginContext.php create mode 100644 src/SilverStripe/BehatExtension/Context/SilverStripeContext.php diff --git a/src/SilverStripe/BehatExtension/Context/BasicContext.php b/src/SilverStripe/BehatExtension/Context/BasicContext.php new file mode 100644 index 0000000..d6235e7 --- /dev/null +++ b/src/SilverStripe/BehatExtension/Context/BasicContext.php @@ -0,0 +1,332 @@ +context = $parameters; + } + + /** + * Get Mink session from MinkContext + */ + public function getSession($name = null) + { + return $this->getMainContext()->getSession($name); + } + + /** + * @AfterStep ~@modal + * + * Excluding scenarios with @modal tag is required, + * because modal dialogs stop any JS interaction + */ + public function appendErrorHandlerBeforeStep(StepEvent $event) + { + $javascript = <<getSession()->executeScript($javascript); + } + + /** + * @AfterStep ~@modal + * + * Excluding scenarios with @modal tag is required, + * because modal dialogs stop any JS interaction + */ + public function readErrorHandlerAfterStep(StepEvent $event) + { + $page = $this->getSession()->getPage(); + + $jserrors = $page->find('xpath', '//body[@data-jserrors]'); + if (null !== $jserrors) { + $this->takeScreenshot($event); + file_put_contents('php://stderr', $jserrors->getAttribute('data-jserrors') . PHP_EOL); + } + + $javascript = <<getSession()->executeScript($javascript); + } + + /** + * Hook into jQuery ajaxStart, ajaxSuccess and ajaxComplete events. + * Prepare __ajaxStatus() functions and attach them to these handlers. + * Event handlers are removed after one run. + * + * @BeforeStep + */ + public function handleAjaxBeforeStep(StepEvent $event) + { + $ajax_enabled_steps = $this->getMainContext()->getAjaxEnabledSteps(); + $ajax_enabled_steps = implode('|', array_filter($ajax_enabled_steps)); + + if (empty($ajax_enabled_steps) || !preg_match('/(' . $ajax_enabled_steps . ')/i', $event->getStep()->getText())) { + return; + } + + $javascript = <<getSession()->executeScript($javascript); + } + + /** + * Wait for the __ajaxStatus()to return anything but 'waiting'. + * Don't wait longer than 5 seconds. + * + * Don't unregister handler if we're dealing with modal windows + * + * @AfterStep ~@modal + */ + public function handleAjaxAfterStep(StepEvent $event) + { + $ajax_enabled_steps = $this->getMainContext()->getAjaxEnabledSteps(); + $ajax_enabled_steps = implode('|', array_filter($ajax_enabled_steps)); + + if (empty($ajax_enabled_steps) || !preg_match('/(' . $ajax_enabled_steps . ')/i', $event->getStep()->getText())) { + return; + } + + $this->handleAjaxTimeout(); + + $javascript = <<getSession()->executeScript($javascript); + } + + public function handleAjaxTimeout() + { + $this->getSession()->wait(5000, + "(typeof window.__ajaxStatus !== 'undefined' ? window.__ajaxStatus() : 'no ajax') !== 'waiting'" + ); + + // wait additional 100ms to allow DOM to update + $this->getSession()->wait(100); + } + + /** + * Take screenshot when step fails. + * Works only with Selenium2Driver. + * + * @AfterStep + */ + public function takeScreenshotAfterFailedStep(StepEvent $event) + { + if (4 === $event->getResult()) { + $this->takeScreenshot($event); + } + } + + public function takeScreenshot(StepEvent $event) { + $driver = $this->getSession()->getDriver(); + // quit silently when unsupported + if (!($driver instanceof Selenium2Driver)) { + return; + } + + $parent = $event->getLogicalParent(); + $feature = $parent->getFeature(); + $step = $event->getStep(); + $screenshot_path = null; + + if (isset($this->context['screenshot_path'])) { + $screenshot_path = realpath($this->context['screenshot_path']); + if (!$screenshot_path) { + \Filesystem::makeFolder($this->context['screenshot_path']); + $screenshot_path = realpath($this->context['screenshot_path']); + } + } + if (!$screenshot_path) { + $screenshot_path = realpath(sys_get_temp_dir()); + } + + if (!file_exists($screenshot_path)) { + file_put_contents('php://stderr', sprintf('"%s" is not valid directory and failed to create it' . PHP_EOL, $this->context['screenshot_path'])); + return; + } + + if (file_exists($screenshot_path) && !is_dir($screenshot_path)) { + file_put_contents('php://stderr', sprintf('"%s" is not valid directory' . PHP_EOL, $this->context['screenshot_path'])); + return; + } + if (file_exists($screenshot_path) && !is_writable($screenshot_path)) { + file_put_contents('php://stderr', sprintf('"%s" directory is not writable' . PHP_EOL, $screenshot_path)); + return; + } + + $screenshot_path = sprintf('%s/%s_%d.png', $screenshot_path, basename($feature->getFile()), $step->getLine()); + $screenshot = $driver->wdSession->screenshot(); + file_put_contents($screenshot_path, base64_decode($screenshot)); + file_put_contents('php://stderr', sprintf('Saving screenshot into %s' . PHP_EOL, $screenshot_path)); + } + + /** + * @Then /^I should be redirected to "([^"]+)"/ + */ + public function stepIShouldBeRedirectedTo($url) + { + if ($this->getMainContext()->canIntercept()) { + $client = $this->getSession()->getDriver()->getClient(); + $client->followRedirects(true); + $client->followRedirect(); + + $url = $this->getMainContext()->joinUrlParts($this->context['base_url'], $url); + + assertTrue($this->getMainContext()->isCurrentUrlSimilarTo($url), sprintf('Current URL is not %s', $url)); + } + } + + /** + * @Given /^I wait for "(\d+)"$/ + */ + public function stepIWaitFor($ms) + { + $this->getSession()->wait($ms); + } + + /** + * @Given /^I press "([^"]*)" button$/ + */ + public function stepIPressButton($button) + { + $page = $this->getSession()->getPage(); + + $button_element = $page->find('named', array('link_or_button', "'$button'")); + assertNotNull($button_element, sprintf('%s button not found', $button)); + + $button_element->click(); + } + + /** + * @Given /^I click "([^"]*)" in the "([^"]*)" element$/ + */ + public function iClickInTheElement($text, $selector) + { + $page = $this->getSession()->getPage(); + + $parent_element = $page->find('css', $selector); + assertNotNull($parent_element, sprintf('"%s" element not found', $selector)); + + $element = $parent_element->find('xpath', sprintf('//*[count(*)=0 and contains(.,"%s")]', $text)); + assertNotNull($element, sprintf('"%s" not found', $text)); + + $element->click(); + } + + /** + * @Given /^I type "([^"]*)" into the dialog$/ + */ + public function iTypeIntoTheDialog($data) + { + $data = array( + 'text' => $data, + ); + $this->getSession()->getDriver()->wdSession->postAlert_text($data); + } + + /** + * @Given /^I confirm the dialog$/ + */ + public function iConfirmTheDialog() + { + $this->getSession()->getDriver()->wdSession->accept_alert(); + $this->handleAjaxTimeout(); + } + + /** + * @Given /^I dismiss the dialog$/ + */ + public function iDismissTheDialog() + { + $this->getSession()->getDriver()->wdSession->dismiss_alert(); + $this->handleAjaxTimeout(); + } + + /** + * @Given /^(I attach the file .*) with HTML5$/ + */ + public function iAttachTheFileTo($step) + { + $this->getSession()->evaluateScript("jQuery('.ss-uploadfield-editandorganize').show()"); + $this->getSession()->evaluateScript("jQuery('[name=\"AssetUploadField\"]').css({opacity:1,visibility:'visible',height:'1px',width:'1px'})"); + $this->getSession()->evaluateScript("jQuery('[name=\"files[]\"]').css({opacity:1,visibility:'visible',height:'1px',width:'1px'})"); + $this->getSession()->wait(1000); + + return new Step\Given($step); + } +} diff --git a/src/SilverStripe/BehatExtension/Context/LoginContext.php b/src/SilverStripe/BehatExtension/Context/LoginContext.php new file mode 100644 index 0000000..f36474b --- /dev/null +++ b/src/SilverStripe/BehatExtension/Context/LoginContext.php @@ -0,0 +1,150 @@ +context = $parameters; + } + + /** + * Get Mink session from MinkContext + */ + public function getSession($name = null) + { + return $this->getMainContext()->getSession($name); + } + + /** + * @Given /^I am logged in$/ + */ + public function stepIAmLoggedIn() + { + $admin_url = $this->getMainContext()->joinUrlParts($this->getMainContext()->getBaseUrl(), $this->context['admin_url']); + $login_url = $this->getMainContext()->joinUrlParts($this->getMainContext()->getBaseUrl(), $this->context['login_url']); + + $this->getSession()->visit($admin_url); + + if (0 == strpos($this->getSession()->getCurrentUrl(), $login_url)) { + $this->stepILogInWith('admin', 'password'); + assertStringStartsWith($admin_url, $this->getSession()->getCurrentUrl()); + } + } + + /** + * @Given /^I am logged in with "([^"]*)" permissions$/ + */ + function iAmLoggedInWithPermissions($permCode) + { + if (!isset($this->cache_generatedMembers[$permCode])) { + $group = \Injector::inst()->create('Group'); + $group->Title = "$permCode group"; + $group->write(); + + $permission = \Injector::inst()->create('Permission'); + $permission->Code = $permCode; + $permission->write(); + $group->Permissions()->add($permission); + + $member = \DataObject::get_one('Member', sprintf('"Email" = \'%s\'', "$permCode@example.org")); + if (!$member) { + $member = \Injector::inst()->create('Member'); + } + + $member->FirstName = $permCode; + $member->Surname = "User"; + $member->Email = "$permCode@example.org"; + $member->changePassword('secret'); + $member->write(); + $group->Members()->add($member); + + $this->cache_generatedMembers[$permCode] = $member; + } + +// $this->cache_generatedMembers[$permCode]->logIn(); + return new Step\Given(sprintf('I log in with "%s" and "%s"', "$permCode@example.org", 'secret')); + } + + /** + * @Given /^I am not logged in$/ + */ + public function stepIAmNotLoggedIn() + { + $this->getSession()->reset(); + } + + /** + * @When /^I log in with "([^"]*)" and "([^"]*)"$/ + */ + public function stepILogInWith($email, $password) + { + $login_url = $this->getMainContext()->joinUrlParts($this->getMainContext()->getBaseUrl(), $this->context['login_url']); + + $this->getSession()->visit($login_url); + + $page = $this->getSession()->getPage(); + + $email_field = $page->find('css', '[name=Email]'); + $password_field = $page->find('css', '[name=Password]'); + $submit_button = $page->find('css', '[type=submit]'); + $email_field->setValue($email); + $password_field->setValue($password); + $submit_button->press(); + } + + /** + * @Given /^I should see a log-in form$/ + */ + public function stepIShouldSeeALogInForm() + { + $page = $this->getSession()->getPage(); + + $login_form = $page->find('css', '#MemberLoginForm_LoginForm'); + assertNotNull($login_form, 'I should see a log-in form'); + } + + /** + * @Then /^I will see a bad log-in message$/ + */ + public function stepIWillSeeABadLogInMessage() + { + $page = $this->getSession()->getPage(); + + $bad_message = $page->find('css', '.message.bad'); + + assertNotNull($bad_message, 'Bad message not found.'); + } +} diff --git a/src/SilverStripe/BehatExtension/Context/SilverStripeContext.php b/src/SilverStripe/BehatExtension/Context/SilverStripeContext.php new file mode 100644 index 0000000..a29bd4a --- /dev/null +++ b/src/SilverStripe/BehatExtension/Context/SilverStripeContext.php @@ -0,0 +1,444 @@ +context = $parameters; + } + + public function setDatabase($database_name) + { + $this->database_name = $database_name; + } + + public function setAjaxEnabledSteps($ajax_steps) + { + if (empty($ajax_steps)) { + $ajax_steps = array(); + } + $this->ajax_steps = $ajax_steps; + } + + public function getAjaxEnabledSteps() + { + return $this->ajax_steps; + } + + public function getFixture($data_object) + { + if (!array_key_exists($data_object, $this->fixtures)) { + throw new \OutOfBoundsException(sprintf('Data object `%s` does not exist!', $data_object)); + } + + return $this->fixtures[$data_object]; + } + + public function getFixtures() + { + return $this->fixtures; + } + + /** + * @BeforeScenario + */ + public function before(ScenarioEvent $event) + { + if (!isset($this->database_name)) { + throw new \LogicException('Context\'s $database_name has to be set when implementing SilverStripeAwareContextInterface.'); + } + + $setdb_url = $this->joinUrlParts($this->getBaseUrl(), '/dev/tests/setdb'); + $setdb_url = sprintf('%s?database=%s', $setdb_url, $this->database_name); + $this->getSession()->visit($setdb_url); + } + + /** + * @BeforeScenario @database-defaults + */ + public function beforeDatabaseDefaults(ScenarioEvent $event) + { + \SapphireTest::empty_temp_db(); + global $databaseConfig; + \DB::connect($databaseConfig); + $dataClasses = \ClassInfo::subclassesFor('DataObject'); + array_shift($dataClasses); + foreach ($dataClasses as $dataClass) { + \singleton($dataClass)->requireDefaultRecords(); + } + } + + /** + * @AfterScenario @database-defaults + */ + public function afterDatabaseDefaults(ScenarioEvent $event) + { + \SapphireTest::empty_temp_db(); + } + + /** + * @AfterScenario @assets + */ + public function afterResetAssets(ScenarioEvent $event) + { + if (is_array($this->created_files_paths)) { + $created_files = array_reverse($this->created_files_paths); + foreach ($created_files as $path) { + if (is_dir($path)) { + \Filesystem::removeFolder($path); + } else { + @unlink($path); + } + } + } + \SapphireTest::empty_temp_db(); + } + + /** + * @Given /^there are the following ([^\s]*) records$/ + */ + public function thereAreTheFollowingRecords($data_object, PyStringNode $string) + { + if (!is_array($this->fixtures)) { + $this->fixtures = array(); + } + if (!is_array($this->fixtures_lazy)) { + $this->fixtures_lazy = array(); + } + if (!isset($this->files_path)) { + $this->files_path = realpath($this->getMinkParameter('files_path')); + } + if (!is_array($this->created_files_paths)) { + $this->created_files_paths = array(); + } + + if (array_key_exists($data_object, $this->fixtures)) { + throw new \InvalidArgumentException(sprintf('Data object `%s` already exists!', $data_object)); + } + + $fixture = array_merge(array($data_object . ':'), $string->getLines()); + $fixture = implode("\n ", $fixture); + + if ('Folder' === $data_object) { + $this->prepareTestAssetsDirectories($fixture); + } + + if ('File' === $data_object) { + $this->prepareTestAssetsFiles($fixture); + } + + $fixtures_lazy = array($data_object => array()); + if (preg_match('/=>(\w+)/', $fixture)) { + $fixture_content = Yaml::parse($fixture); + foreach ($fixture_content[$data_object] as $identifier => &$fields) { + foreach ($fields as $field_val) { + if (substr($field_val, 0, 2) == '=>') { + $fixtures_lazy[$data_object][$identifier] = $fixture_content[$data_object][$identifier]; + unset($fixture_content[$data_object][$identifier]); + } + } + } + $fixture = Yaml::dump($fixture_content); + } + + // As we're dealing with split fixtures and can't join them, replace references by hand +// if (preg_match('/=>(\w+)\.([\w.]+)/', $fixture, $matches)) { +// if ($matches[1] !== $data_object) { +// $fixture = preg_replace_callback('/=>(\w+)\.([\w.]+)/', array($this, 'replaceFixtureReferences'), $fixture); +// } +// } + $fixture = preg_replace_callback('/=>(\w+)\.([\w.]+)/', array($this, 'replaceFixtureReferences'), $fixture); + // Save fixtures into database + $this->fixtures[$data_object] = new \YamlFixture($fixture); + $model = \DataModel::inst(); + $this->fixtures[$data_object]->saveIntoDatabase($model); + // Lazy load fixtures into database + // Loop is required for nested lazy fixtures + foreach ($fixtures_lazy[$data_object] as $identifier => $fields) { + $fixture = array( + $data_object => array( + $identifier => $fields, + ), + ); + $fixture = Yaml::dump($fixture); + $fixture = preg_replace_callback('/=>(\w+)\.([\w.]+)/', array($this, 'replaceFixtureReferences'), $fixture); + $this->fixtures_lazy[$data_object][$identifier] = new \YamlFixture($fixture); + $this->fixtures_lazy[$data_object][$identifier]->saveIntoDatabase($model); + } + } + + protected function prepareTestAssetsDirectories($fixture) + { + $folders = Yaml::parse($fixture); + foreach ($folders['Folder'] as $fields) { + foreach ($fields as $field => $value) { + if ('Filename' === $field) { + if (0 === strpos($value, 'assets/')) { + $value = substr($value, strlen('assets/')); + } + + $folder_path = ASSETS_PATH . DIRECTORY_SEPARATOR . $value; + if (file_exists($folder_path) && !is_dir($folder_path)) { + throw new \Exception(sprintf('`%s` already exists and is not a directory', $this->files_path)); + } + + \Filesystem::makeFolder($folder_path); + $this->created_files_paths[] = $folder_path; + } + } + } + } + + protected function prepareTestAssetsFiles($fixture) + { + $files = Yaml::parse($fixture); + foreach ($files['File'] as $fields) { + foreach ($fields as $field => $value) { + if ('Filename' === $field) { + if (0 === strpos($value, 'assets/')) { + $value = substr($value, strlen('assets/')); + } + + $file_path = $this->files_path . DIRECTORY_SEPARATOR . basename($value); + if (!file_exists($file_path) || !is_file($file_path)) { + throw new \Exception(sprintf('`%s` does not exist or is not a file', $this->files_path)); + } + $asset_path = ASSETS_PATH . DIRECTORY_SEPARATOR . $value; + if (file_exists($asset_path) && !is_file($asset_path)) { + throw new \Exception(sprintf('`%s` already exists and is not a file', $this->files_path)); + } + + if (!file_exists($asset_path)) { + if (@copy($file_path, $asset_path)) { + $this->created_files_paths[] = $asset_path; + } + } + } + } + } + } + + protected function replaceFixtureReferences($references) + { + if (!array_key_exists($references[1], $this->fixtures)) { + throw new \OutOfBoundsException(sprintf('Data object `%s` does not exist!', $references[1])); + } + return $this->idFromFixture($references[1], $references[2]); + } + + protected function idFromFixture($class_name, $identifier) + { + if (false !== ($id = $this->fixtures[$class_name]->idFromFixture($class_name, $identifier))) { + return $id; + } + if (isset($this->fixtures_lazy[$class_name], $this->fixtures_lazy[$class_name][$identifier]) && + false !== ($id = $this->fixtures_lazy[$class_name][$identifier]->idFromFixture($class_name, $identifier))) { + return $id; + } + + throw new \OutOfBoundsException(sprintf('`%s` identifier in Data object `%s` does not exist!', $identifier, $class_name)); + } + + /** + * Parses given URL and returns its components + * + * @param $url + * @return array|mixed Parsed URL + */ + public function parseUrl($url) + { + $url = parse_url($url); + $url['vars'] = array(); + if (!isset($url['fragment'])) { + $url['fragment'] = null; + } + if (isset($url['query'])) { + parse_str($url['query'], $url['vars']); + } + + return $url; + } + + /** + * Checks whether current URL is close enough to the given URL. + * Unless specified in $url, get vars will be ignored + * Unless specified in $url, fragment identifiers will be ignored + * + * @param $url string URL to compare to current URL + * @return boolean Returns true if the current URL is close enough to the given URL, false otherwise. + */ + public function isCurrentUrlSimilarTo($url) + { + $current = $this->parseUrl($this->getSession()->getCurrentUrl()); + $test = $this->parseUrl($url); + + if ($current['path'] !== $test['path']) { + return false; + } + + if (isset($test['fragment']) && $current['fragment'] !== $test['fragment']) { + return false; + } + + foreach ($test['vars'] as $name => $value) { + if (!isset($current['vars'][$name]) || $current['vars'][$name] !== $value) { + return false; + } + } + + return true; + } + + /** + * Returns base URL parameter set in MinkExtension. + * It simplifies configuration by allowing to specify this parameter + * once but makes code dependent on MinkExtension. + * + * @return string + */ + public function getBaseUrl() + { + return $this->getMinkParameter('base_url') ?: ''; + } + + /** + * Joins URL parts into an URL using forward slash. + * Forward slash usages are normalised to one between parts. + * This method takes variable number of parameters. + * + * @param $... + * @return string + * @throws \InvalidArgumentException + */ + public function joinUrlParts() + { + if (0 === func_num_args()) { + throw new \InvalidArgumentException('Need at least one argument'); + } + + $parts = func_get_args(); + $trim_slashes = function(&$part) { + $part = trim($part, '/'); + }; + array_walk($parts, $trim_slashes); + + return implode('/', $parts); + } + + public function canIntercept() + { + $driver = $this->getSession()->getDriver(); + if ($driver instanceof GoutteDriver) { + return true; + } + else { + if ($driver instanceof Selenium2Driver) { + return false; + } + } + + throw new UnsupportedDriverActionException('You need to tag the scenario with "@mink:goutte" or "@mink:symfony". Intercepting the redirections is not supported by %s', $driver); + } + + /** + * @Given /^(.*) without redirection$/ + */ + public function theRedirectionsAreIntercepted($step) + { + if ($this->canIntercept()) { + $this->getSession()->getDriver()->getClient()->followRedirects(false); + } + + return new Step\Given($step); + } + + /** + * @Given /^((?:I )fill in =>(.+?) for "([^"]*)")$/ + */ + public function iFillInFor($step, $reference, $field) + { + if (false === strpos($reference, '.')) { + throw new \Exception('Fixture reference should be in following format: =>ClassName.identifier'); + } + + list($class_name, $identifier) = explode('.', $reference); + $id = $this->idFromFixture($class_name, $identifier); + //$step = preg_replace('#=>(.+?) for "([^"]*)"#', '"'.$id.'" for "'.$field.'"', $step); + + // below is not working, because Selenium can't interact with hidden inputs + // return new Step\Given($step); + + // TODO: investigate how to simplify this and make universal + $javascript = <<getSession()->executeScript($javascript); + } + + /** + * @Given /^((?:I )fill in "([^"]*)" with =>(.+))$/ + */ + public function iFillInWith($step, $field, $reference) + { + if (false === strpos($reference, '.')) { + throw new \Exception('Fixture reference should be in following format: =>ClassName.identifier'); + } + + list($class_name, $identifier) = explode('.', $reference); + $id = $this->idFromFixture($class_name, $identifier); + //$step = preg_replace('#"([^"]*)" with =>(.+)#', '"'.$field.'" with "'.$id.'"', $step); + + // below is not working, because Selenium can't interact with hidden inputs + // return new Step\Given($step); + + // TODO: investigate how to simplify this and make universal + $javascript = <<getSession()->executeScript($javascript); + } +}