<?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, * wildcards supported * * * #### Examples: * * ``` yaml * extensions: * enabled: * - Codeception\Extension\Recorder: * module: AngularJS # enable for Angular * delete_successful: false # keep screenshots of successful tests * ignore_steps: [have, grab*] * ``` * */ class Recorder extends \Codeception\Extension { protected $config = [ 'delete_successful' => true, 'module' => 'WebDriver', 'template' => null, 'animate_slides' => true, 'ignore_steps' => [] ]; 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/3.3.5/css/bootstrap.min.css" rel="stylesheet"> <style> html, body { height: 100%; } .carousel, .item, .active { height: 100%; } .navbar { margin-bottom: 0px !important; } .carousel-caption { background: rgba(0,0,0,0.8); padding-bottom: 50px !important; } .carousel-caption.error { background: #c0392b !important; } .carousel-inner { height: 100%; } .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; } </style> </head> <body> <!-- Navigation --> <nav class="navbar navbar-default" role="navigation"> <div class="navbar-header"> <a class="navbar-brand" href="#">{{feature}} <small>{{test}}</small> </a> </div> </nav> <header id="steps" class="carousel{{carousel_class}}"> <!-- Indicators --> <ol class="carousel-indicators"> {{indicators}} </ol> <!-- Wrapper for Slides --> <div class="carousel-inner"> {{slides}} </div> <!-- Controls --> <a class="left carousel-control" href="#steps" data-slide="prev"> <span class="icon-prev"></span> </a> <a class="right carousel-control" href="#steps" data-slide="next"> <span class="icon-next"></span> </a> </header> <!-- jQuery --> <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/2.1.4/jquery.min.js"></script> <script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.5/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; protected $indicatorTemplate = <<<EOF <li data-target="#steps" data-slide-to="{{step}}" {{isActive}}></li> EOF; 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/3.3.5/css/bootstrap.min.css" rel="stylesheet"> </head> <body> <!-- Navigation --> <nav class="navbar navbar-default" role="navigation"> <div class="navbar-header"> <a class="navbar-brand" href="#">Recorded Tests </a> </div> </nav> <div class="container"> <h1>Record #{{seed}}</h1> <ul> {{records}} </ul> </div> </body> </html> EOF; protected $slidesTemplate = <<<EOF <div class="item {{isActive}}"> <div class="fill"> <img src="{{image}}"> </div> <div class="carousel-caption {{isError}}"> <h2>{{caption}}</h2> <small>scroll up and down to see the full page</small> </div> </div> EOF; 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; protected $dir; protected $slides = []; protected $stepNum = 0; protected $seed; protected $recordedTests = []; 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->webDriverModule = $this->getModule($this->config['module']); if (!$this->webDriverModule instanceof ScreenshotSaver) { throw new ExtensionException( $this, 'You should pass module which implements Codeception\Lib\Interfaces\ScreenshotSaver 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}_{testname}</debug> ----"); } public function afterSuite() { if (!$this->webDriverModule or !$this->dir) { return; } $links = ''; foreach ($this->recordedTests as $link => $url) { $links .= "<li><a href='$url'>$link</a></li>\n"; } $indexHTML = (new Template($this->indexTemplate)) ->place('seed', $this->seed) ->place('records', $links) ->produce(); file_put_contents(codecept_output_dir().'records.html', $indexHTML); $this->writeln("⏺ Records saved into: <info>file://" . codecept_output_dir().'records.html</info>'); } public function before(TestEvent $e) { if (!$this->webDriverModule) { return; } $this->dir = null; $this->stepNum = 0; $this->slides = []; $testName = preg_replace('~\W~', '_', Descriptor::getTestAsString($e->getTest())); $this->dir = codecept_output_dir() . "record_{$this->seed}_$testName"; @mkdir($this->dir); } public function cleanup(TestEvent $e) { if (!$this->webDriverModule or !$this->dir) { return; } if (!$this->config['delete_successful']) { $this->persist($e); return; } // deleting successfully executed tests FileSystem::deleteDir($this->dir); } public function persist(TestEvent $e) { if (!$this->webDriverModule or !$this->dir) { return; } $indicatorHtml = ''; $slideHtml = ''; foreach ($this->slides as $i => $step) { $indicatorHtml .= (new Template($this->indicatorTemplate)) ->place('step', (int)$i) ->place('isActive', (int)$i ? '' : 'class="active"') ->produce(); $slideHtml .= (new Template($this->slidesTemplate)) ->place('image', $i) ->place('caption', $step->getHtml('#3498db')) ->place('isActive', (int)$i ? '' : 'active') ->place('isError', $step->hasFailed() ? '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'; file_put_contents($indexFile, $html); $testName = Descriptor::getTestSignature($e->getTest()). ' - '.ucfirst($e->getTest()->getFeature()); $this->recordedTests[$testName] = substr($indexFile, strlen(codecept_output_dir())); } public function afterStep(StepEvent $e) { if (!$this->webDriverModule or !$this->dir) { return; } if ($e->getStep() instanceof CommentStep) { return; } if ($this->isStepIgnored($e->getStep())) { return; } $filename = str_pad($this->stepNum, 3, "0", STR_PAD_LEFT) . '.png'; $this->webDriverModule->_saveScreenshot($this->dir . DIRECTORY_SEPARATOR . $filename); $this->stepNum++; $this->slides[$filename] = $e->getStep(); } /** * @param Step $step * @return bool */ protected function isStepIgnored($step) { foreach ($this->config['ignore_steps'] as $stepPattern) { $stepRegexp = '/^' . str_replace('*', '.*?', $stepPattern) . '$/i'; if (preg_match($stepRegexp, $step->getAction())) { return true; } } return false; } }