123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638 |
- <?php
- namespace Codeception\Extension;
- use Codeception\Event\StepEvent;
- use Codeception\Event\TestEvent;
- use Codeception\Events;
- use Codeception\Exception\ExtensionException;
- use Codeception\Lib\Interfaces\ScreenshotSaver;
- use Codeception\Module\WebDriver;
- use Codeception\Step;
- use Codeception\Step\Comment as CommentStep;
- use Codeception\Test\Descriptor;
- use Codeception\Util\FileSystem;
- use Codeception\Util\Template;
- /**
- * Saves a screenshot of each step in acceptance tests and shows them as a slideshow on one HTML page (here's an [example](http://codeception.com/images/recorder.gif))
- * Activated only for suites with WebDriver module enabled.
- *
- * The screenshots are saved to `tests/_output/record_*` directories, open `index.html` to see them as a slideshow.
- *
- * #### Installation
- *
- * Add this to the list of enabled extensions in `codeception.yml` or `acceptance.suite.yml`:
- *
- * ``` yaml
- * extensions:
- * enabled:
- * - Codeception\Extension\Recorder
- * ```
- *
- * #### Configuration
- *
- * * `delete_successful` (default: true) - delete screenshots for successfully passed tests (i.e. log only failed and errored tests).
- * * `module` (default: WebDriver) - which module for screenshots to use. Set `AngularJS` if you want to use it with AngularJS module. Generally, the module should implement `Codeception\Lib\Interfaces\ScreenshotSaver` interface.
- * * `ignore_steps` (default: []) - array of step names that should not be recorded (given the step passed), * wildcards supported. Meta steps can also be ignored.
- * * `success_color` (default: success) - bootstrap values to be used for color representation for passed tests
- * * `failure_color` (default: danger) - bootstrap values to be used for color representation for failed tests
- * * `error_color` (default: dark) - bootstrap values to be used for color representation for scenarios where there's an issue occurred while generating a recording
- * * `delete_orphaned` (default: false) - delete recording folders created via previous runs
- *
- * #### Examples:
- *
- * ``` yaml
- * extensions:
- * enabled:
- * - Codeception\Extension\Recorder:
- * module: AngularJS # enable for Angular
- * delete_successful: false # keep screenshots of successful tests
- * ignore_steps: [have, grab*]
- * ```
- * #### Skipping recording of steps with annotations
- *
- * It is also possible to skip recording of steps for specified tests by using the @skipRecording annotation.
- *
- * ```php
- * /**
- * * @skipRecording login
- * * @skipRecording amOnUrl
- * *\/
- * public function testLogin(AcceptanceTester $I)
- * {
- * $I->login();
- * $I->amOnUrl('http://codeception.com');
- * }
- * ```
- *
- */
- class Recorder extends \Codeception\Extension
- {
- /** @var array */
- protected $config = [
- 'delete_successful' => true,
- 'module' => 'WebDriver',
- 'template' => null,
- 'animate_slides' => true,
- 'ignore_steps' => [],
- 'success_color' => 'success',
- 'failure_color' => 'danger',
- 'error_color' => 'dark',
- 'delete_orphaned' => false,
- ];
- /** @var string */
- protected $template = <<<EOF
- <!DOCTYPE html>
- <html lang="en">
- <head>
- <meta charset="utf-8">
- <meta name="viewport" content="width=device-width, initial-scale=1">
- <title>Recorder Result</title>
- <!-- Bootstrap Core CSS -->
- <link href="https://maxcdn.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css" rel="stylesheet">
- <style>
- html,
- body {
- height: 100%;
- }
- .active {
- height: 100%;
- }
- .carousel-caption {
- background: rgba(0,0,0,0.8);
- }
- .carousel-caption.error {
- background: #c0392b !important;
- }
- .carousel-item {
- min-height: 100vh;
- }
- .fill {
- width: 100%;
- height: 100%;
- text-align: center;
- overflow-y: scroll;
- background-position: top;
- -webkit-background-size: cover;
- -moz-background-size: cover;
- background-size: cover;
- -o-background-size: cover;
- }
- .gradient-right {
- background:
- linear-gradient(to left, rgba(0,0,0,.4), rgba(0,0,0,.0))
- }
- .gradient-left {
- background:
- linear-gradient(to right, rgba(0,0,0,.4), rgba(0,0,0,.0))
- }
- </style>
- </head>
- <body>
- <!-- Navigation -->
- <nav class="navbar navbar-expand-lg navbar-light bg-light" role="navigation">
- <div class="navbar-header">
- <a class="navbar-brand" href="../records.html"></span>Recorded Tests</a>
- </div>
- <div class="collapse navbar-collapse" id="navbarText">
- <ul class="navbar-nav mr-auto">
- <span class="navbar-text">{{feature}}</span>
- </ul>
- <span class="navbar-text">{{test}}</span>
- </div>
- </nav>
- <header id="steps" class="carousel slide" data-ride="carousel">
- <!-- Indicators -->
- <ol class="carousel-indicators">
- {{indicators}}
- </ol>
- <!-- Wrapper for Slides -->
- <div class="carousel-inner">
- {{slides}}
- </div>
- <!-- Controls -->
- <a class="carousel-control-prev gradient-left" href="#steps" role="button" data-slide="prev">
- <span class="carousel-control-prev-icon" aria-hidden="false"></span>
- <span class="sr-only">Previous</span>
- </a>
- <a class="carousel-control-next gradient-right" href="#steps" role="button" data-slide="next">
- <span class="carousel-control-next-icon" aria-hidden="false"></span>
- <span class="sr-only">Next</span>
- </a>
- </header>
- <!-- jQuery -->
- <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
- <script src="https://maxcdn.bootstrapcdn.com/bootstrap/4.1.3/js/bootstrap.min.js"></script>
- <!-- Script to Activate the Carousel -->
- <script>
- $('.carousel').carousel({
- wrap: true,
- interval: false
- })
- $(document).bind('keyup', function(e) {
- if(e.keyCode==39){
- jQuery('a.carousel-control.right').trigger('click');
- }
- else if(e.keyCode==37){
- jQuery('a.carousel-control.left').trigger('click');
- }
- });
- </script>
- </body>
- </html>
- EOF;
- /** @var string */
- protected $indicatorTemplate = <<<EOF
- <li data-target="#steps" data-slide-to="{{step}}" class="{{isActive}}"></li>
- EOF;
- /** @var string */
- protected $indexTemplate = <<<EOF
- <!DOCTYPE html>
- <html lang="en">
- <head>
- <meta charset="utf-8">
- <meta name="viewport" content="width=device-width, initial-scale=1">
- <title>Recorder Results Index</title>
- <link href="https://maxcdn.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css" rel="stylesheet">
- </head>
- <body>
- <!-- Navigation -->
- <nav class="navbar navbar-expand-lg navbar-light bg-light" role="navigation">
- <div class="navbar-header">
- <a class="navbar-brand" href="#">Recorded Tests
- </a>
- </div>
- </nav>
- <div class="container py-4">
- <h1>Record #{{seed}}</h1>
- <ul>
- {{records}}
- </ul>
- </div>
- </body>
- </html>
- EOF;
- /** @var string */
- protected $slidesTemplate = <<<EOF
- <div class="carousel-item {{isActive}}">
- <img class="mx-auto d-block mh-100" src="{{image}}">
- <div class="carousel-caption {{isError}}">
- <h5>{{caption}}</h5>
- <p>scroll up and down to see the full page</p>
- </div>
- </div>
- EOF;
- /** @var array */
- public static $events = [
- Events::SUITE_BEFORE => 'beforeSuite',
- Events::SUITE_AFTER => 'afterSuite',
- Events::TEST_BEFORE => 'before',
- Events::TEST_ERROR => 'persist',
- Events::TEST_FAIL => 'persist',
- Events::TEST_SUCCESS => 'cleanup',
- Events::STEP_AFTER => 'afterStep',
- ];
- /** @var WebDriver */
- protected $webDriverModule;
- /** @var string */
- protected $dir;
- /** @var array */
- protected $slides = [];
- /** @var int */
- protected $stepNum = 0;
- /** @var string */
- protected $seed;
- /** @var array */
- protected $seeds;
- /** @var array */
- protected $recordedTests = [];
- /** @var array */
- protected $skipRecording = [];
- /** @var array */
- protected $errorMessages = [];
- /** @var bool */
- protected $colors;
- /** @var bool */
- protected $ansi;
- public function beforeSuite()
- {
- $this->webDriverModule = null;
- if (!$this->hasModule($this->config['module'])) {
- $this->writeln('Recorder is disabled, no available modules');
- return;
- }
- $this->seed = uniqid();
- $this->seeds[] = $this->seed;
- $this->webDriverModule = $this->getModule($this->config['module']);
- $this->skipRecording = [];
- $this->errorMessages = [];
- $this->ansi = !isset($this->options['no-ansi']);
- $this->colors = !isset($this->options['no-colors']);
- if (!$this->webDriverModule instanceof ScreenshotSaver) {
- throw new ExtensionException(
- $this,
- 'You should pass module which implements ' . ScreenshotSaver::class . ' interface'
- );
- }
- $this->writeln(
- sprintf(
- '⏺ <bold>Recording</bold> ⏺ step-by-step screenshots will be saved to <info>%s</info>',
- codecept_output_dir()
- )
- );
- $this->writeln("Directory Format: <debug>record_{$this->seed}_{filename}_{testname}</debug> ----");
- }
- public function afterSuite()
- {
- if (!$this->webDriverModule) {
- return;
- }
- $links = '';
- if (count($this->slides)) {
- foreach ($this->recordedTests as $suiteName => $suite) {
- $links .= "<ul><li><b>{$suiteName}</b></li><ul>";
- foreach ($suite as $fileName => $tests) {
- $links .= "<li>{$fileName}</li><ul>";
- foreach ($tests as $test) {
- $links .= in_array($test['path'], $this->skipRecording, true)
- ? "<li class=\"text{$this->config['error_color']}\">{$test['name']}</li>\n"
- : '<li class="text-' . $this->config[$test['status'] . '_color']
- . "\"><a href='{$test['index']}'>{$test['name']}</a></li>\n";
- }
- $links .= '</ul>';
- }
- $links .= '</ul></ul>';
- }
- $indexHTML = (new Template($this->indexTemplate))
- ->place('seed', $this->seed)
- ->place('records', $links)
- ->produce();
- try {
- file_put_contents(codecept_output_dir() . 'records.html', $indexHTML);
- } catch (\Exception $exception) {
- $this->writeln(
- "⏺ An exception occurred while saving records.html: <info>{$exception->getMessage()}</info>"
- );
- }
- $this->writeln('⏺ Records saved into: <info>file://' . codecept_output_dir() . 'records.html</info>');
- }
- foreach ($this->errorMessages as $message) {
- $this->writeln($message);
- }
- }
- /**
- * @param TestEvent $e
- */
- public function before(TestEvent $e)
- {
- if (!$this->webDriverModule) {
- return;
- }
- $this->dir = null;
- $this->stepNum = 0;
- $this->slides = [];
- $this->dir = codecept_output_dir() . "record_{$this->seed}_{$this->getTestName($e)}";
- $testPath = codecept_relative_path(Descriptor::getTestFullName($e->getTest()));
- try {
- !is_dir($this->dir) && !mkdir($this->dir) && !is_dir($this->dir);
- } catch (\Exception $exception) {
- $this->skipRecording[] = $testPath;
- $this->appendErrorMessage(
- $testPath,
- "⏺ An exception occurred while creating directory: <info>{$this->dir}</info>"
- );
- }
- }
- /**
- * @param TestEvent $e
- */
- public function cleanup(TestEvent $e)
- {
- if ($this->config['delete_orphaned']) {
- $recordingDirectories = [];
- $directories = new \DirectoryIterator(codecept_output_dir());
- // getting a list of currently present recording directories
- foreach ($directories as $directory) {
- preg_match('/^record_(.*?)_[^\n]+.php_[^\n]+$/', $directory->getFilename(), $match);
- if (isset($match[1])) {
- $recordingDirectories[$match[1]][] = codecept_output_dir() . $directory->getFilename();
- }
- }
- // removing orphaned recording directories
- foreach (array_diff(array_keys($recordingDirectories), $this->seeds) as $orphanedSeed) {
- foreach ($recordingDirectories[$orphanedSeed] as $orphanedDirectory) {
- FileSystem::deleteDir($orphanedDirectory);
- }
- }
- }
- if (!$this->webDriverModule || !$this->dir) {
- return;
- }
- if (!$this->config['delete_successful']) {
- $this->persist($e);
- return;
- }
- // deleting successfully executed tests
- FileSystem::deleteDir($this->dir);
- }
- /**
- * @param TestEvent $e
- */
- public function persist(TestEvent $e)
- {
- if (!$this->webDriverModule) {
- return;
- }
- $indicatorHtml = '';
- $slideHtml = '';
- $testName = $this->getTestName($e);
- $testPath = codecept_relative_path(Descriptor::getTestFullName($e->getTest()));
- $dir = codecept_output_dir() . "record_{$this->seed}_$testName";
- $status = 'success';
- if (strcasecmp($this->dir, $dir) !== 0) {
- $filename = str_pad(0, 3, '0', STR_PAD_LEFT) . '.png';
- try {
- !is_dir($dir) && !mkdir($dir) && !is_dir($dir);
- $this->dir = $dir;
- } catch (\Exception $exception) {
- $this->skipRecording[] = $testPath;
- $this->appendErrorMessage(
- $testPath,
- "⏺ An exception occurred while creating directory: <info>{$dir}</info>"
- );
- }
- $this->slides = [];
- $this->slides[$filename] = new Step\Action('encountered an unexpected error prior to the test execution');
- $status = 'error';
- try {
- if ($this->webDriverModule->webDriver === null) {
- throw new ExtensionException($this, 'Failed to save screenshot as webDriver is not set');
- }
- $this->webDriverModule->webDriver->takeScreenshot($this->dir . DIRECTORY_SEPARATOR . $filename);
- } catch (\Exception $exception) {
- $this->appendErrorMessage(
- $testPath,
- "⏺ Unable to capture a screenshot for <info>{$testPath}/before</info>"
- );
- }
- }
- if (!in_array($testPath, $this->skipRecording, true)) {
- foreach ($this->slides as $i => $step) {
- if ($step->hasFailed()) {
- $status = 'failure';
- }
- $indicatorHtml .= (new Template($this->indicatorTemplate))
- ->place('step', (int)$i)
- ->place('isActive', (int)$i ? '' : 'active')
- ->produce();
- $slideHtml .= (new Template($this->slidesTemplate))
- ->place('image', $i)
- ->place('caption', $step->getHtml('#3498db'))
- ->place('isActive', (int)$i ? '' : 'active')
- ->place('isError', $status === 'success' ? '' : 'error')
- ->produce();
- }
- $html = (new Template($this->template))
- ->place('indicators', $indicatorHtml)
- ->place('slides', $slideHtml)
- ->place('feature', ucfirst($e->getTest()->getFeature()))
- ->place('test', Descriptor::getTestSignature($e->getTest()))
- ->place('carousel_class', $this->config['animate_slides'] ? ' slide' : '')
- ->produce();
- $indexFile = $this->dir . DIRECTORY_SEPARATOR . 'index.html';
- $environment = $e->getTest()->getMetadata()->getCurrent('env') ?: '';
- $suite = ucfirst(basename(\dirname($e->getTest()->getMetadata()->getFilename())));
- $testName = basename($e->getTest()->getMetadata()->getFilename());
- try {
- file_put_contents($indexFile, $html);
- } catch (\Exception $exception) {
- $this->skipRecording[] = $testPath;
- $this->appendErrorMessage(
- $testPath,
- "⏺ An exception occurred while saving index.html for <info>{$testPath}: "
- . "{$exception->getMessage()}</info>"
- );
- }
- $this->recordedTests["{$suite} ({$environment})"][$testName][] = [
- 'name' => $e->getTest()->getMetadata()->getName(),
- 'path' => $testPath,
- 'status' => $status,
- 'index' => substr($indexFile, strlen(codecept_output_dir())),
- ];
- }
- }
- /**
- * @param StepEvent $e
- */
- public function afterStep(StepEvent $e)
- {
- if ($this->webDriverModule === null || $this->dir === null) {
- return;
- }
- if ($e->getStep() instanceof CommentStep) {
- return;
- }
- // only taking the ignore step into consideration if that step has passed
- if ($this->isStepIgnored($e) && !$e->getStep()->hasFailed()) {
- return;
- }
- $filename = str_pad($this->stepNum, 3, '0', STR_PAD_LEFT) . '.png';
- try {
- if ($this->webDriverModule->webDriver === null) {
- throw new ExtensionException($this, 'Failed to save screenshot as webDriver is not set');
- }
- $this->webDriverModule->webDriver->takeScreenshot($this->dir . DIRECTORY_SEPARATOR . $filename);
- } catch (\Exception $exception) {
- $testPath = codecept_relative_path(Descriptor::getTestFullName($e->getTest()));
- $this->appendErrorMessage(
- $testPath,
- "⏺ Unable to capture a screenshot for <info>{$testPath}/{$e->getStep()->getAction()}</info>"
- );
- }
- $this->stepNum++;
- $this->slides[$filename] = $e->getStep();
- }
- /**
- * @param StepEvent $e
- *
- * @return bool
- */
- protected function isStepIgnored(StepEvent $e)
- {
- $configIgnoredSteps = $this->config['ignore_steps'];
- $annotationIgnoredSteps = $e->getTest()->getMetadata()->getParam('skipRecording');
- $ignoredSteps = array_unique(
- array_merge(
- $configIgnoredSteps,
- is_array($annotationIgnoredSteps) ? $annotationIgnoredSteps : []
- )
- );
- foreach ($ignoredSteps as $stepPattern) {
- $stepRegexp = '/^' . str_replace('*', '.*?', $stepPattern) . '$/i';
- if (preg_match($stepRegexp, $e->getStep()->getAction())) {
- return true;
- }
- if ($e->getStep()->getMetaStep() !== null &&
- preg_match($stepRegexp, $e->getStep()->getMetaStep()->getAction())
- ) {
- return true;
- }
- }
- return false;
- }
- /**
- * @param StepEvent|TestEvent $e
- *
- * @return string
- */
- private function getTestName($e)
- {
- return basename($e->getTest()->getMetadata()->getFilename()) . '_' . $e->getTest()->getMetadata()->getName();
- }
- /**
- * @param string $message
- */
- protected function writeln($message)
- {
- parent::writeln(
- $this->ansi
- ? $message
- : trim(preg_replace('/[ ]{2,}/', ' ', str_replace('⏺', '', $message)))
- );
- }
- /**
- * @param string $testPath
- * @param string $message
- */
- private function appendErrorMessage($testPath, $message)
- {
- $this->errorMessages[$testPath] = array_merge(
- array_key_exists($testPath, $this->errorMessages) ? $this->errorMessages[$testPath]: [],
- [$message]
- );
- }
- }
|