Recorder.php 20 KB

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