Recorder.php 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371
  1. <?php
  2. namespace Codeception\Extension;
  3. use Codeception\Event\StepEvent;
  4. use Codeception\Event\TestEvent;
  5. use Codeception\Events;
  6. use Codeception\Exception\ExtensionException;
  7. use Codeception\Lib\Interfaces\ScreenshotSaver;
  8. use Codeception\Module\WebDriver;
  9. use Codeception\Step;
  10. use Codeception\Step\Comment as CommentStep;
  11. use Codeception\Test\Descriptor;
  12. use Codeception\Util\FileSystem;
  13. use Codeception\Util\Template;
  14. /**
  15. * 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))
  16. * Activated only for suites with WebDriver module enabled.
  17. *
  18. * The screenshots are saved to `tests/_output/record_*` directories, open `index.html` to see them as a slideshow.
  19. *
  20. * #### Installation
  21. *
  22. * Add this to the list of enabled extensions in `codeception.yml` or `acceptance.suite.yml`:
  23. *
  24. * ``` yaml
  25. * extensions:
  26. * enabled:
  27. * - Codeception\Extension\Recorder
  28. * ```
  29. *
  30. * #### Configuration
  31. *
  32. * * `delete_successful` (default: true) - delete screenshots for successfully passed tests (i.e. log only failed and errored tests).
  33. * * `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.
  34. * * `ignore_steps` (default: []) - array of step names that should not be recorded, * wildcards supported
  35. *
  36. *
  37. * #### Examples:
  38. *
  39. * ``` yaml
  40. * extensions:
  41. * enabled:
  42. * - Codeception\Extension\Recorder:
  43. * module: AngularJS # enable for Angular
  44. * delete_successful: false # keep screenshots of successful tests
  45. * ignore_steps: [have, grab*]
  46. * ```
  47. *
  48. */
  49. class Recorder extends \Codeception\Extension
  50. {
  51. protected $config = [
  52. 'delete_successful' => true,
  53. 'module' => 'WebDriver',
  54. 'template' => null,
  55. 'animate_slides' => true,
  56. 'ignore_steps' => []
  57. ];
  58. protected $template = <<<EOF
  59. <!DOCTYPE html>
  60. <html lang="en">
  61. <head>
  62. <meta charset="utf-8">
  63. <meta name="viewport" content="width=device-width, initial-scale=1">
  64. <title>Recorder Result</title>
  65. <!-- Bootstrap Core CSS -->
  66. <link href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.5/css/bootstrap.min.css" rel="stylesheet">
  67. <style>
  68. html,
  69. body {
  70. height: 100%;
  71. }
  72. .carousel,
  73. .item,
  74. .active {
  75. height: 100%;
  76. }
  77. .navbar {
  78. margin-bottom: 0px !important;
  79. }
  80. .carousel-caption {
  81. background: rgba(0,0,0,0.8);
  82. padding-bottom: 50px !important;
  83. }
  84. .carousel-caption.error {
  85. background: #c0392b !important;
  86. }
  87. .carousel-inner {
  88. height: 100%;
  89. }
  90. .fill {
  91. width: 100%;
  92. height: 100%;
  93. text-align: center;
  94. overflow-y: scroll;
  95. background-position: top;
  96. -webkit-background-size: cover;
  97. -moz-background-size: cover;
  98. background-size: cover;
  99. -o-background-size: cover;
  100. }
  101. </style>
  102. </head>
  103. <body>
  104. <!-- Navigation -->
  105. <nav class="navbar navbar-default" role="navigation">
  106. <div class="navbar-header">
  107. <a class="navbar-brand" href="#">{{feature}}
  108. <small>{{test}}</small>
  109. </a>
  110. </div>
  111. </nav>
  112. <header id="steps" class="carousel{{carousel_class}}">
  113. <!-- Indicators -->
  114. <ol class="carousel-indicators">
  115. {{indicators}}
  116. </ol>
  117. <!-- Wrapper for Slides -->
  118. <div class="carousel-inner">
  119. {{slides}}
  120. </div>
  121. <!-- Controls -->
  122. <a class="left carousel-control" href="#steps" data-slide="prev">
  123. <span class="icon-prev"></span>
  124. </a>
  125. <a class="right carousel-control" href="#steps" data-slide="next">
  126. <span class="icon-next"></span>
  127. </a>
  128. </header>
  129. <!-- jQuery -->
  130. <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/2.1.4/jquery.min.js"></script>
  131. <script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.5/js/bootstrap.min.js"></script>
  132. <!-- Script to Activate the Carousel -->
  133. <script>
  134. $('.carousel').carousel({
  135. wrap: true,
  136. interval: false
  137. })
  138. $(document).bind('keyup', function(e) {
  139. if(e.keyCode==39){
  140. jQuery('a.carousel-control.right').trigger('click');
  141. }
  142. else if(e.keyCode==37){
  143. jQuery('a.carousel-control.left').trigger('click');
  144. }
  145. });
  146. </script>
  147. </body>
  148. </html>
  149. EOF;
  150. protected $indicatorTemplate = <<<EOF
  151. <li data-target="#steps" data-slide-to="{{step}}" {{isActive}}></li>
  152. EOF;
  153. protected $indexTemplate = <<<EOF
  154. <!DOCTYPE html>
  155. <html lang="en">
  156. <head>
  157. <meta charset="utf-8">
  158. <meta name="viewport" content="width=device-width, initial-scale=1">
  159. <title>Recorder Results Index</title>
  160. <link href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.5/css/bootstrap.min.css" rel="stylesheet">
  161. </head>
  162. <body>
  163. <!-- Navigation -->
  164. <nav class="navbar navbar-default" role="navigation">
  165. <div class="navbar-header">
  166. <a class="navbar-brand" href="#">Recorded Tests
  167. </a>
  168. </div>
  169. </nav>
  170. <div class="container">
  171. <h1>Record #{{seed}}</h1>
  172. <ul>
  173. {{records}}
  174. </ul>
  175. </div>
  176. </body>
  177. </html>
  178. EOF;
  179. protected $slidesTemplate = <<<EOF
  180. <div class="item {{isActive}}">
  181. <div class="fill">
  182. <img src="{{image}}">
  183. </div>
  184. <div class="carousel-caption {{isError}}">
  185. <h2>{{caption}}</h2>
  186. <small>scroll up and down to see the full page</small>
  187. </div>
  188. </div>
  189. EOF;
  190. public static $events = [
  191. Events::SUITE_BEFORE => 'beforeSuite',
  192. Events::SUITE_AFTER => 'afterSuite',
  193. Events::TEST_BEFORE => 'before',
  194. Events::TEST_ERROR => 'persist',
  195. Events::TEST_FAIL => 'persist',
  196. Events::TEST_SUCCESS => 'cleanup',
  197. Events::STEP_AFTER => 'afterStep',
  198. ];
  199. /**
  200. * @var WebDriver
  201. */
  202. protected $webDriverModule;
  203. protected $dir;
  204. protected $slides = [];
  205. protected $stepNum = 0;
  206. protected $seed;
  207. protected $recordedTests = [];
  208. public function beforeSuite()
  209. {
  210. $this->webDriverModule = null;
  211. if (!$this->hasModule($this->config['module'])) {
  212. $this->writeln("Recorder is disabled, no available modules");
  213. return;
  214. }
  215. $this->seed = uniqid();
  216. $this->webDriverModule = $this->getModule($this->config['module']);
  217. if (!$this->webDriverModule instanceof ScreenshotSaver) {
  218. throw new ExtensionException(
  219. $this,
  220. 'You should pass module which implements Codeception\Lib\Interfaces\ScreenshotSaver interface'
  221. );
  222. }
  223. $this->writeln(sprintf(
  224. "⏺ <bold>Recording</bold> ⏺ step-by-step screenshots will be saved to <info>%s</info>",
  225. codecept_output_dir()
  226. ));
  227. $this->writeln("Directory Format: <debug>record_{$this->seed}_{testname}</debug> ----");
  228. }
  229. public function afterSuite()
  230. {
  231. if (!$this->webDriverModule or !$this->dir) {
  232. return;
  233. }
  234. $links = '';
  235. foreach ($this->recordedTests as $link => $url) {
  236. $links .= "<li><a href='$url'>$link</a></li>\n";
  237. }
  238. $indexHTML = (new Template($this->indexTemplate))
  239. ->place('seed', $this->seed)
  240. ->place('records', $links)
  241. ->produce();
  242. file_put_contents(codecept_output_dir().'records.html', $indexHTML);
  243. $this->writeln("⏺ Records saved into: <info>file://" . codecept_output_dir().'records.html</info>');
  244. }
  245. public function before(TestEvent $e)
  246. {
  247. if (!$this->webDriverModule) {
  248. return;
  249. }
  250. $this->dir = null;
  251. $this->stepNum = 0;
  252. $this->slides = [];
  253. $testName = preg_replace('~\W~', '_', Descriptor::getTestAsString($e->getTest()));
  254. $this->dir = codecept_output_dir() . "record_{$this->seed}_$testName";
  255. @mkdir($this->dir);
  256. }
  257. public function cleanup(TestEvent $e)
  258. {
  259. if (!$this->webDriverModule or !$this->dir) {
  260. return;
  261. }
  262. if (!$this->config['delete_successful']) {
  263. $this->persist($e);
  264. return;
  265. }
  266. // deleting successfully executed tests
  267. FileSystem::deleteDir($this->dir);
  268. }
  269. public function persist(TestEvent $e)
  270. {
  271. if (!$this->webDriverModule or !$this->dir) {
  272. return;
  273. }
  274. $indicatorHtml = '';
  275. $slideHtml = '';
  276. foreach ($this->slides as $i => $step) {
  277. $indicatorHtml .= (new Template($this->indicatorTemplate))
  278. ->place('step', (int)$i)
  279. ->place('isActive', (int)$i ? '' : 'class="active"')
  280. ->produce();
  281. $slideHtml .= (new Template($this->slidesTemplate))
  282. ->place('image', $i)
  283. ->place('caption', $step->getHtml('#3498db'))
  284. ->place('isActive', (int)$i ? '' : 'active')
  285. ->place('isError', $step->hasFailed() ? 'error' : '')
  286. ->produce();
  287. }
  288. $html = (new Template($this->template))
  289. ->place('indicators', $indicatorHtml)
  290. ->place('slides', $slideHtml)
  291. ->place('feature', ucfirst($e->getTest()->getFeature()))
  292. ->place('test', Descriptor::getTestSignature($e->getTest()))
  293. ->place('carousel_class', $this->config['animate_slides'] ? ' slide' : '')
  294. ->produce();
  295. $indexFile = $this->dir . DIRECTORY_SEPARATOR . 'index.html';
  296. file_put_contents($indexFile, $html);
  297. $testName = Descriptor::getTestSignature($e->getTest()). ' - '.ucfirst($e->getTest()->getFeature());
  298. $this->recordedTests[$testName] = substr($indexFile, strlen(codecept_output_dir()));
  299. }
  300. public function afterStep(StepEvent $e)
  301. {
  302. if (!$this->webDriverModule or !$this->dir) {
  303. return;
  304. }
  305. if ($e->getStep() instanceof CommentStep) {
  306. return;
  307. }
  308. if ($this->isStepIgnored($e->getStep())) {
  309. return;
  310. }
  311. $filename = str_pad($this->stepNum, 3, "0", STR_PAD_LEFT) . '.png';
  312. $this->webDriverModule->_saveScreenshot($this->dir . DIRECTORY_SEPARATOR . $filename);
  313. $this->stepNum++;
  314. $this->slides[$filename] = $e->getStep();
  315. }
  316. /**
  317. * @param Step $step
  318. * @return bool
  319. */
  320. protected function isStepIgnored($step)
  321. {
  322. foreach ($this->config['ignore_steps'] as $stepPattern) {
  323. $stepRegexp = '/^' . str_replace('*', '.*?', $stepPattern) . '$/i';
  324. if (preg_match($stepRegexp, $step->getAction())) {
  325. return true;
  326. }
  327. }
  328. return false;
  329. }
  330. }