Recorder.php 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605
  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 (given the step passed), * wildcards supported.
  35. * * `success_color` (default: success) - bootstrap values to be used for color representation for passed tests
  36. * * `failure_color` (default: danger) - bootstrap values to be used for color representation for failed tests
  37. * * `error_color` (default: dark) - bootstrap values to be used for color representation for scenarios where there's an issue occurred while generating a recording
  38. * * `delete_orphaned` (default: false) - delete recording folders created via previous runs
  39. *
  40. * #### Examples:
  41. *
  42. * ``` yaml
  43. * extensions:
  44. * enabled:
  45. * - Codeception\Extension\Recorder:
  46. * module: AngularJS # enable for Angular
  47. * delete_successful: false # keep screenshots of successful tests
  48. * ignore_steps: [have, grab*]
  49. * ```
  50. *
  51. */
  52. class Recorder extends \Codeception\Extension
  53. {
  54. /** @var array */
  55. protected $config = [
  56. 'delete_successful' => true,
  57. 'module' => 'WebDriver',
  58. 'template' => null,
  59. 'animate_slides' => true,
  60. 'ignore_steps' => [],
  61. 'success_color' => 'success',
  62. 'failure_color' => 'danger',
  63. 'error_color' => 'dark',
  64. 'delete_orphaned' => false,
  65. ];
  66. /** @var string */
  67. protected $template = <<<EOF
  68. <!DOCTYPE html>
  69. <html lang="en">
  70. <head>
  71. <meta charset="utf-8">
  72. <meta name="viewport" content="width=device-width, initial-scale=1">
  73. <title>Recorder Result</title>
  74. <!-- Bootstrap Core CSS -->
  75. <link href="https://maxcdn.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css" rel="stylesheet">
  76. <style>
  77. html,
  78. body {
  79. height: 100%;
  80. }
  81. .active {
  82. height: 100%;
  83. }
  84. .carousel-caption {
  85. background: rgba(0,0,0,0.8);
  86. }
  87. .carousel-caption.error {
  88. background: #c0392b !important;
  89. }
  90. .carousel-item {
  91. min-height: 100vh;
  92. }
  93. .fill {
  94. width: 100%;
  95. height: 100%;
  96. text-align: center;
  97. overflow-y: scroll;
  98. background-position: top;
  99. -webkit-background-size: cover;
  100. -moz-background-size: cover;
  101. background-size: cover;
  102. -o-background-size: cover;
  103. }
  104. .gradient-right {
  105. background:
  106. linear-gradient(to left, rgba(0,0,0,.4), rgba(0,0,0,.0))
  107. }
  108. .gradient-left {
  109. background:
  110. linear-gradient(to right, rgba(0,0,0,.4), rgba(0,0,0,.0))
  111. }
  112. </style>
  113. </head>
  114. <body>
  115. <!-- Navigation -->
  116. <nav class="navbar navbar-expand-lg navbar-light bg-light" role="navigation">
  117. <div class="navbar-header">
  118. <a class="navbar-brand" href="../records.html"></span>Recorded Tests</a>
  119. </div>
  120. <div class="collapse navbar-collapse" id="navbarText">
  121. <ul class="navbar-nav mr-auto">
  122. <span class="navbar-text">{{feature}}</span>
  123. </ul>
  124. <span class="navbar-text">{{test}}</span>
  125. </div>
  126. </nav>
  127. <header id="steps" class="carousel slide" data-ride="carousel">
  128. <!-- Indicators -->
  129. <ol class="carousel-indicators">
  130. {{indicators}}
  131. </ol>
  132. <!-- Wrapper for Slides -->
  133. <div class="carousel-inner">
  134. {{slides}}
  135. </div>
  136. <!-- Controls -->
  137. <a class="carousel-control-prev gradient-left" href="#steps" role="button" data-slide="prev">
  138. <span class="carousel-control-prev-icon" aria-hidden="false"></span>
  139. <span class="sr-only">Previous</span>
  140. </a>
  141. <a class="carousel-control-next gradient-right" href="#steps" role="button" data-slide="next">
  142. <span class="carousel-control-next-icon" aria-hidden="false"></span>
  143. <span class="sr-only">Next</span>
  144. </a>
  145. </header>
  146. <!-- jQuery -->
  147. <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
  148. <script src="https://maxcdn.bootstrapcdn.com/bootstrap/4.1.3/js/bootstrap.min.js"></script>
  149. <!-- Script to Activate the Carousel -->
  150. <script>
  151. $('.carousel').carousel({
  152. wrap: true,
  153. interval: false
  154. })
  155. $(document).bind('keyup', function(e) {
  156. if(e.keyCode==39){
  157. jQuery('a.carousel-control.right').trigger('click');
  158. }
  159. else if(e.keyCode==37){
  160. jQuery('a.carousel-control.left').trigger('click');
  161. }
  162. });
  163. </script>
  164. </body>
  165. </html>
  166. EOF;
  167. /** @var string */
  168. protected $indicatorTemplate = <<<EOF
  169. <li data-target="#steps" data-slide-to="{{step}}" class="{{isActive}}"></li>
  170. EOF;
  171. /** @var string */
  172. protected $indexTemplate = <<<EOF
  173. <!DOCTYPE html>
  174. <html lang="en">
  175. <head>
  176. <meta charset="utf-8">
  177. <meta name="viewport" content="width=device-width, initial-scale=1">
  178. <title>Recorder Results Index</title>
  179. <link href="https://maxcdn.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css" rel="stylesheet">
  180. </head>
  181. <body>
  182. <!-- Navigation -->
  183. <nav class="navbar navbar-expand-lg navbar-light bg-light" role="navigation">
  184. <div class="navbar-header">
  185. <a class="navbar-brand" href="#">Recorded Tests
  186. </a>
  187. </div>
  188. </nav>
  189. <div class="container py-4">
  190. <h1>Record #{{seed}}</h1>
  191. <ul>
  192. {{records}}
  193. </ul>
  194. </div>
  195. </body>
  196. </html>
  197. EOF;
  198. /** @var string */
  199. protected $slidesTemplate = <<<EOF
  200. <div class="carousel-item {{isActive}}">
  201. <img class="mx-auto d-block mh-100" src="{{image}}">
  202. <div class="carousel-caption {{isError}}">
  203. <h5>{{caption}}</h5>
  204. <p>scroll up and down to see the full page</p>
  205. </div>
  206. </div>
  207. EOF;
  208. /** @var array */
  209. public static $events = [
  210. Events::SUITE_BEFORE => 'beforeSuite',
  211. Events::SUITE_AFTER => 'afterSuite',
  212. Events::TEST_BEFORE => 'before',
  213. Events::TEST_ERROR => 'persist',
  214. Events::TEST_FAIL => 'persist',
  215. Events::TEST_SUCCESS => 'cleanup',
  216. Events::STEP_AFTER => 'afterStep',
  217. ];
  218. /** @var WebDriver */
  219. protected $webDriverModule;
  220. /** @var string */
  221. protected $dir;
  222. /** @var array */
  223. protected $slides = [];
  224. /** @var int */
  225. protected $stepNum = 0;
  226. /** @var string */
  227. protected $seed;
  228. /** @var array */
  229. protected $seeds;
  230. /** @var array */
  231. protected $recordedTests = [];
  232. /** @var array */
  233. protected $skipRecording = [];
  234. /** @var array */
  235. protected $errorMessages = [];
  236. /** @var bool */
  237. protected $colors;
  238. /** @var bool */
  239. protected $ansi;
  240. public function beforeSuite()
  241. {
  242. $this->webDriverModule = null;
  243. if (!$this->hasModule($this->config['module'])) {
  244. $this->writeln('Recorder is disabled, no available modules');
  245. return;
  246. }
  247. $this->seed = uniqid();
  248. $this->seeds[] = $this->seed;
  249. $this->webDriverModule = $this->getModule($this->config['module']);
  250. $this->skipRecording = [];
  251. $this->errorMessages = [];
  252. $this->ansi = !isset($this->options['no-ansi']);
  253. $this->colors = !isset($this->options['no-colors']);
  254. if (!$this->webDriverModule instanceof ScreenshotSaver) {
  255. throw new ExtensionException(
  256. $this,
  257. 'You should pass module which implements ' . ScreenshotSaver::class . ' interface'
  258. );
  259. }
  260. $this->writeln(
  261. sprintf(
  262. '⏺ <bold>Recording</bold> ⏺ step-by-step screenshots will be saved to <info>%s</info>',
  263. codecept_output_dir()
  264. )
  265. );
  266. $this->writeln("Directory Format: <debug>record_{$this->seed}_{filename}_{testname}</debug> ----");
  267. }
  268. public function afterSuite()
  269. {
  270. if (!$this->webDriverModule) {
  271. return;
  272. }
  273. $links = '';
  274. if (count($this->slides)) {
  275. foreach ($this->recordedTests as $suiteName => $suite) {
  276. $links .= "<ul><li><b>{$suiteName}</b></li><ul>";
  277. foreach ($suite as $fileName => $tests) {
  278. $links .= "<li>{$fileName}</li><ul>";
  279. foreach ($tests as $test) {
  280. $links .= in_array($test['path'], $this->skipRecording, true)
  281. ? "<li class=\"text{$this->config['error_color']}\">{$test['name']}</li>\n"
  282. : '<li class="text-' . $this->config[$test['status'] . '_color']
  283. . "\"><a href='{$test['index']}'>{$test['name']}</a></li>\n";
  284. }
  285. $links .= '</ul>';
  286. }
  287. $links .= '</ul></ul>';
  288. }
  289. $indexHTML = (new Template($this->indexTemplate))
  290. ->place('seed', $this->seed)
  291. ->place('records', $links)
  292. ->produce();
  293. try {
  294. file_put_contents(codecept_output_dir() . 'records.html', $indexHTML);
  295. } catch (\Exception $exception) {
  296. $this->writeln(
  297. "⏺ An exception occurred while saving records.html: <info>{$exception->getMessage()}</info>"
  298. );
  299. }
  300. $this->writeln('⏺ Records saved into: <info>file://' . codecept_output_dir() . 'records.html</info>');
  301. }
  302. foreach ($this->errorMessages as $message) {
  303. $this->writeln($message);
  304. }
  305. }
  306. /**
  307. * @param TestEvent $e
  308. */
  309. public function before(TestEvent $e)
  310. {
  311. if (!$this->webDriverModule) {
  312. return;
  313. }
  314. $this->dir = null;
  315. $this->stepNum = 0;
  316. $this->slides = [];
  317. $this->dir = codecept_output_dir() . "record_{$this->seed}_{$this->getTestName($e)}";
  318. $testPath = codecept_relative_path(Descriptor::getTestFullName($e->getTest()));
  319. try {
  320. !is_dir($this->dir) && !mkdir($this->dir) && !is_dir($this->dir);
  321. } catch (\Exception $exception) {
  322. $this->skipRecording[] = $testPath;
  323. $this->appendErrorMessage(
  324. $testPath,
  325. "⏺ An exception occurred while creating directory: <info>{$this->dir}</info>"
  326. );
  327. }
  328. }
  329. /**
  330. * @param TestEvent $e
  331. */
  332. public function cleanup(TestEvent $e)
  333. {
  334. if ($this->config['delete_orphaned']) {
  335. $recordingDirectories = [];
  336. $directories = new \DirectoryIterator(codecept_output_dir());
  337. // getting a list of currently present recording directories
  338. foreach ($directories as $directory) {
  339. preg_match('/^record_(.*?)_[^\n]+.php_[^\n]+$/', $directory->getFilename(), $match);
  340. if (isset($match[1])) {
  341. $recordingDirectories[$match[1]][] = codecept_output_dir() . $directory->getFilename();
  342. }
  343. }
  344. // removing orphaned recording directories
  345. foreach (array_diff(array_keys($recordingDirectories), $this->seeds) as $orphanedSeed) {
  346. foreach ($recordingDirectories[$orphanedSeed] as $orphanedDirectory) {
  347. FileSystem::deleteDir($orphanedDirectory);
  348. }
  349. }
  350. }
  351. if (!$this->webDriverModule || !$this->dir) {
  352. return;
  353. }
  354. if (!$this->config['delete_successful']) {
  355. $this->persist($e);
  356. return;
  357. }
  358. // deleting successfully executed tests
  359. FileSystem::deleteDir($this->dir);
  360. }
  361. /**
  362. * @param TestEvent $e
  363. */
  364. public function persist(TestEvent $e)
  365. {
  366. if (!$this->webDriverModule) {
  367. return;
  368. }
  369. $indicatorHtml = '';
  370. $slideHtml = '';
  371. $testName = $this->getTestName($e);
  372. $testPath = codecept_relative_path(Descriptor::getTestFullName($e->getTest()));
  373. $dir = codecept_output_dir() . "record_{$this->seed}_$testName";
  374. $status = 'success';
  375. if (strcasecmp($this->dir, $dir) !== 0) {
  376. $filename = str_pad(0, 3, '0', STR_PAD_LEFT) . '.png';
  377. try {
  378. !is_dir($dir) && !mkdir($dir) && !is_dir($dir);
  379. $this->dir = $dir;
  380. } catch (\Exception $exception) {
  381. $this->skipRecording[] = $testPath;
  382. $this->appendErrorMessage(
  383. $testPath,
  384. "⏺ An exception occurred while creating directory: <info>{$dir}</info>"
  385. );
  386. }
  387. $this->slides = [];
  388. $this->slides[$filename] = new Step\Action('encountered an unexpected error prior to the test execution');
  389. $status = 'error';
  390. try {
  391. if ($this->webDriverModule->webDriver === null) {
  392. throw new ExtensionException($this, 'Failed to save screenshot as webDriver is not set');
  393. }
  394. $this->webDriverModule->webDriver->takeScreenshot($this->dir . DIRECTORY_SEPARATOR . $filename);
  395. } catch (\Exception $exception) {
  396. $this->appendErrorMessage(
  397. $testPath,
  398. "⏺ Unable to capture a screenshot for <info>{$testPath}/before</info>"
  399. );
  400. }
  401. }
  402. if (!in_array($testPath, $this->skipRecording, true)) {
  403. foreach ($this->slides as $i => $step) {
  404. if ($step->hasFailed()) {
  405. $status = 'failure';
  406. }
  407. $indicatorHtml .= (new Template($this->indicatorTemplate))
  408. ->place('step', (int)$i)
  409. ->place('isActive', (int)$i ? '' : 'active')
  410. ->produce();
  411. $slideHtml .= (new Template($this->slidesTemplate))
  412. ->place('image', $i)
  413. ->place('caption', $step->getHtml('#3498db'))
  414. ->place('isActive', (int)$i ? '' : 'active')
  415. ->place('isError', $status === 'success' ? '' : 'error')
  416. ->produce();
  417. }
  418. $html = (new Template($this->template))
  419. ->place('indicators', $indicatorHtml)
  420. ->place('slides', $slideHtml)
  421. ->place('feature', ucfirst($e->getTest()->getFeature()))
  422. ->place('test', Descriptor::getTestSignature($e->getTest()))
  423. ->place('carousel_class', $this->config['animate_slides'] ? ' slide' : '')
  424. ->produce();
  425. $indexFile = $this->dir . DIRECTORY_SEPARATOR . 'index.html';
  426. $environment = $e->getTest()->getMetadata()->getCurrent('env') ?: '';
  427. $suite = ucfirst(basename(\dirname($e->getTest()->getMetadata()->getFilename())));
  428. $testName = basename($e->getTest()->getMetadata()->getFilename());
  429. try {
  430. file_put_contents($indexFile, $html);
  431. } catch (\Exception $exception) {
  432. $this->skipRecording[] = $testPath;
  433. $this->appendErrorMessage(
  434. $testPath,
  435. "⏺ An exception occurred while saving index.html for <info>{$testPath}: "
  436. . "{$exception->getMessage()}</info>"
  437. );
  438. }
  439. $this->recordedTests["{$suite} ({$environment})"][$testName][] = [
  440. 'name' => $e->getTest()->getMetadata()->getName(),
  441. 'path' => $testPath,
  442. 'status' => $status,
  443. 'index' => substr($indexFile, strlen(codecept_output_dir())),
  444. ];
  445. }
  446. }
  447. /**
  448. * @param StepEvent $e
  449. */
  450. public function afterStep(StepEvent $e)
  451. {
  452. if ($this->webDriverModule === null || $this->dir === null) {
  453. return;
  454. }
  455. if ($e->getStep() instanceof CommentStep) {
  456. return;
  457. }
  458. // only taking the ignore step into consideration if that step has passed
  459. if ($this->isStepIgnored($e->getStep()) && !$e->getStep()->hasFailed()) {
  460. return;
  461. }
  462. $filename = str_pad($this->stepNum, 3, '0', STR_PAD_LEFT) . '.png';
  463. try {
  464. if ($this->webDriverModule->webDriver === null) {
  465. throw new ExtensionException($this, 'Failed to save screenshot as webDriver is not set');
  466. }
  467. $this->webDriverModule->webDriver->takeScreenshot($this->dir . DIRECTORY_SEPARATOR . $filename);
  468. } catch (\Exception $exception) {
  469. $testPath = codecept_relative_path(Descriptor::getTestFullName($e->getTest()));
  470. $this->appendErrorMessage(
  471. $testPath,
  472. "⏺ Unable to capture a screenshot for <info>{$testPath}/{$e->getStep()->getAction()}</info>"
  473. );
  474. }
  475. $this->stepNum++;
  476. $this->slides[$filename] = $e->getStep();
  477. }
  478. /**
  479. * @param Step $step
  480. *
  481. * @return bool
  482. */
  483. protected function isStepIgnored($step)
  484. {
  485. foreach ($this->config['ignore_steps'] as $stepPattern) {
  486. $stepRegexp = '/^' . str_replace('*', '.*?', $stepPattern) . '$/i';
  487. if (preg_match($stepRegexp, $step->getAction())) {
  488. return true;
  489. }
  490. }
  491. return false;
  492. }
  493. /**
  494. * @param StepEvent|TestEvent $e
  495. *
  496. * @return string
  497. */
  498. private function getTestName($e)
  499. {
  500. return basename($e->getTest()->getMetadata()->getFilename()) . '_' . $e->getTest()->getMetadata()->getName();
  501. }
  502. /**
  503. * @param string $message
  504. */
  505. protected function writeln($message)
  506. {
  507. parent::writeln(
  508. $this->ansi
  509. ? $message
  510. : trim(preg_replace('/[ ]{2,}/', ' ', str_replace('⏺', '', $message)))
  511. );
  512. }
  513. /**
  514. * @param string $testPath
  515. * @param string $message
  516. */
  517. private function appendErrorMessage($testPath, $message)
  518. {
  519. $this->errorMessages[$testPath] = array_merge(
  520. array_key_exists($testPath, $this->errorMessages) ? $this->errorMessages[$testPath]: [],
  521. [$message]
  522. );
  523. }
  524. }