diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..52f8fa9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/vendor +/composer.lock +.DS_Store +.idea +*.yml diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a8fc851 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2016 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..49886a2 --- /dev/null +++ b/README.md @@ -0,0 +1,72 @@ +slack-codeception-extension +============================= +This package provides an extension for Codeception to send test results to Slack channels and/or users. + +Pre-requisites +------------- + +- a pre-configured webhook from the Slack integration "Incoming Webhook" +(see https://api.slack.com/incoming-webhooks for more information) + +Installation +----------- + +Add the package `ngraf/slack-codeception-extension` to `composer.json` manually or type this in console: + + composer require ngraf/slack-codeception-extension + +Usage +----- +Enable and configure the extension in your `codeception.yaml` + +**Basic** usage: + + extensions: + enabled: + - Codeception\Extension\SlackExtension + config: + Codeception\Extension\SlackExtension: + webhook: https://hooks.slack.com/services/... + +**Advanced** usage: + + extensions: + enabled: + - Codeception\Extension\SlackExtension + config: + Codeception\Extension\SlackExtension: + webhook: https://hooks.slack.com/services/... + + # possible notification strategies: always|successonly|failonly|failandrecover|statuschange + strategy: always + + # If 'true' details about failed tests will be displayed. Default value: 'false' + extended: true + + # Limit the size of error messages in extended mode. 0 = unlimited. Default value: 80 + extendedMaxLength: 80 + + # Limit the amount of reported errors in extended mode. 0 = unlimited. Default value: 0 + extendedMaxErrors: 10 + + # customize your message with additional prefix and/or suffix + + messagePrefix: '*Smoke-Test*' + messageSuffix: + messageSuffixOnFail: + + # optional config keys that will overwrite the default configuration of the webhook + + channel: '#any-channel,@any-user' + channelOnFail: '#any-channel,@any-user' + username: CI + icon: :ghost: + +Example +----- + +![slack-example](slack-example.png) + +Dependencies +----- +This package uses the package [maknz/slack](https://github.com/maknz/slack) to communicate with the Slack API. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..b74abe8 --- /dev/null +++ b/composer.json @@ -0,0 +1,25 @@ +{ + "name": "ngraf/slack-codeception-extension", + "description": "This package provides an extension for Codeception to broadcast test results in Slack messenger ", + "keywords": [ + "codeception", + "slack", + "extension" + ], + "authors": [ + { + "name": "Norbert Graf", + "email": "derbimmel@gmail.com", + "homepage": "https://github.com/ngraf", + "role": "Developer" + } + ], + "autoload": { + "psr-4": { + "Codeception\\": "src" + } + }, + "require": { + "alek13/slack": "^2.0" + } +} diff --git a/slack-example.png b/slack-example.png new file mode 100644 index 0000000..d439113 Binary files /dev/null and b/slack-example.png differ diff --git a/src/Extension/SlackExtension.php b/src/Extension/SlackExtension.php new file mode 100644 index 0000000..bcdd2e3 --- /dev/null +++ b/src/Extension/SlackExtension.php @@ -0,0 +1,344 @@ + 'sendTestResults', + ); + + /** + * @var Message + */ + protected $message; + + /** + * @var string + */ + protected $messagePrefix; + + /** + * @var string + */ + protected $messageSuffix = ''; + + /** + * @var string + */ + protected $messageSuffixOnFail = ''; + + /** + * @var array Array of slack channels + */ + protected $channels = []; + + /** + * @var array Array of slack channels for the special case of failure. + * Defined by Codeception config "channelOnFail". + * This Codeception configuration is optional, otherwise "channel" will be used. + */ + protected $channelOnFail; + + /** + * @var boolean If set to true notifications will be send only when at least one test fails. + */ + protected $strategy = self::STRATEGY_ALWAYS; + + protected $strategies = array( + self::STRATEGY_ALWAYS, + self::STRATEGY_FAIL_ONLY, + self::STRATEGY_FAIL_AND_RECOVER, + self::STRATEGY_STATUS_CHANGE, + self::STRATEGY_SUCCESS_ONLY + ); + + /** + * @var bool Whether or not to yet extended details about failed tests. + */ + protected $extended = false; + + /** + * @var bool + */ + protected $lastRunFailed; + + /** + * @var \Maknz\Slack\Client + */ + protected $client; + + /** + * @var int Maximum length for error messages to be displayed in extended mode. + */ + protected $extendedMaxLength = 80; + + /** + * @var int|null The maximum amount of errors to send + */ + protected $extendedMaxErrors = 0; + + /** + * Setup Slack client and message object. + * + * @throws ExtensionException in case required configuration for 'webhook' is missing + */ + public function _initialize() + { + if (!isset($this->config['webhook']) or empty($this->config['webhook'])) { + throw new ExtensionException($this, "configuration for 'webhook' is missing"); + } + + $this->client = new Client($this->config['webhook']); + + if (isset($this->config['channel'])) { + if (true === empty($this->config['channel'])) { + throw new ExtensionException( + $this, "SlackExtension: The specified value for key \"channel\" must not be empty." + ); + } + $this->channels = explode(',', $this->config['channel']); + } + + if (isset($this->config['username'])) { + $this->client->setDefaultUsername($this->config['username']); + } + + if (isset($this->config['icon'])) { + $this->client->setDefaultIcon($this->config['icon']); + } + + if (isset($this->config['messagePrefix'])) { + $this->messagePrefix = $this->config['messagePrefix'] . ' '; + } + + if (isset($this->config['messageSuffix'])) { + $messageSuffix = $this->config['messageSuffix']; + + if (substr($this->config['messageSuffix'], 0, 1) === '"') { + $messageSuffix = substr($messageSuffix, 1); + } + + if (substr($this->config['messageSuffix'], -1) === '"') { + $messageSuffix = substr($messageSuffix, 0, strlen($messageSuffix) - 1); + } + + $this->messageSuffix = ' ' . $messageSuffix; + } + + if (isset($this->config['messageSuffixOnFail'])) { + $this->messageSuffixOnFail = ' ' . $this->config['messageSuffixOnFail']; + } + if (isset($this->config['channelOnFail'])) { + if (true === empty($this->config['channelOnFail'])) { + throw new ExtensionException( + $this, "SlackExtension: The specified value for key \"channelOnFail\" must not be empty." + ); + } + $this->channelOnFail = explode(',', $this->config['channelOnFail']); + } + + if (isset($this->config['strategy'])) { + if (false === in_array( + $this->config['strategy'], + $this->strategies + ) + ) { + throw new ExtensionException( + $this, + '"' . $this->config['strategy'] . '" is not a valid Slack notification "strategy".' + . ' Possible values are: ' . PHP_EOL + . implode(',', $this->strategies) + ); + } + $this->strategy = $this->config['strategy']; + } + + if (isset($this->config['extended']) + && (true === $this->config['extended'] || 'true' === $this->config['extended']) + ) { + $this->extended = true; + } + + if (isset($this->config['extendedMaxErrors'])) { + $this->extendedMaxErrors = max((int) $this->config['extendedMaxErrors'], 0); + } + + if (isset($this->config['extendedMaxLength'])) { + $this->extendedMaxLength = intval($this->config['extendedMaxLength']); + } + + $this->lastRunFailed = $this->hasLastRunFailed(); + + $this->message = $this->client->createMessage(); + } + + /** + * Sends test results to Slack channels. + * + * This method is fired when the event 'result.print.after' occurs. + * @param \Codeception\Event\PrintResultEvent $e + */ + public function sendTestResults(\Codeception\Event\PrintResultEvent $e) + { + if (is_null($this->client)) { + return; + } + + $result = $e->getResult(); + + if ($result->wasSuccessful()) { + + if (self::STRATEGY_ALWAYS === $this->strategy + || self::STRATEGY_SUCCESS_ONLY === $this->strategy + || ($this->lastRunFailed && $this->strategy === self::STRATEGY_FAIL_AND_RECOVER) + || ($this->lastRunFailed && $this->strategy === self::STRATEGY_STATUS_CHANGE) + ) { + $this->sendSuccessMessage($result); + } + + } else { + + if (self::STRATEGY_ALWAYS === $this->strategy + || self::STRATEGY_FAIL_ONLY === $this->strategy + || self::STRATEGY_FAIL_AND_RECOVER === $this->strategy + || ($this->strategy === self::STRATEGY_STATUS_CHANGE && false === $this->lastRunFailed) + ) { + $this->sendFailMessage($result); + } + } + } + + /** + * Sends success message to Slack channels. + * + * @param TestResult $result + */ + private function sendSuccessMessage(TestResult $result) + { + $numberOfTests = $result->count(); + + foreach ($this->channels as $channel) { + $this->message->setChannel(trim($channel)); + $this->message->send( + ':white_check_mark: ' + . $this->messagePrefix + . $numberOfTests . ' of ' . $numberOfTests . ' tests passed.' + . str_replace('\\n', PHP_EOL, $this->messageSuffix) + ); + } + } + + /** + * Sends fail message to Slack channels. + * + * @param TestResult $result + */ + private function sendFailMessage(TestResult $result) + { + $numberOfTests = $result->count(); + $numberOfFailedTests = $result->failureCount() + $result->errorCount(); + + if (true === $this->extended) { + $this->attachExtendedInformation($this->message, $result); + } + + $targetChannels = (is_array($this->channelOnFail) && count($this->channelOnFail) > 0) ? + $this->channelOnFail : $this->channels; + + foreach ($targetChannels as $channel) { + $this->message->setChannel(trim($channel)); + + $this->message->send( + ':interrobang: ' + . $this->messagePrefix + . $numberOfFailedTests . ' of ' . $numberOfTests . ' tests failed.' + . str_replace('\\n', PHP_EOL, $this->messageSuffix) + . $this->messageSuffixOnFail + ); + } + } + + /** + * + * @param TestResult $result + */ + private function attachExtendedInformation(Message &$message, TestResult $result) { + $fields = []; + $failures = array_merge($result->failures(), $result->errors()); + $omittedFailures = 0; + if ($this->extendedMaxErrors > 0) { + $omittedFailures = count($failures) - $this->extendedMaxErrors; + $failures = array_slice($failures, 0, $this->extendedMaxErrors); + } + + foreach ($failures as $failure) { + /** + * @var $failure TestFailure + */ + $exceptionMsg = strtok($failure->exceptionMessage(), "\n"); + + $result = json_decode($exceptionMsg); + + if (json_last_error() === JSON_ERROR_NONE && isset($result->errorMessage)) { + $exceptionMsg = $result->errorMessage; + } + + if ($this->extendedMaxLength > 0 && strlen($exceptionMsg) > $this->extendedMaxLength) { + $exceptionMsg = substr($exceptionMsg, 0, $this->extendedMaxLength) . ' ...'; + } + + $fields[] = [ + 'title' => $failure->getTestName(), + 'value' => $exceptionMsg + ]; + } + + if ($omittedFailures > 0) { + $fields[] = [ + 'title' => sprintf('%d other tests...', $omittedFailures), + 'value' => '', + ]; + } + + $message->attach([ + 'color' => 'danger', + 'fields' => $fields + ]); + } + + private function hasLastRunFailed() + { + return is_file($this->getLogDir() . 'failed'); + } +}