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 = << Recorder Result EOF; /** @var string */ protected $indicatorTemplate = << EOF; /** @var string */ protected $indexTemplate = << Recorder Results Index

Record #{{seed}}

    {{records}}
EOF; /** @var string */ protected $slidesTemplate = << 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( '⏺ Recording ⏺ step-by-step screenshots will be saved to %s', codecept_output_dir() ) ); $this->writeln("Directory Format: record_{$this->seed}_{filename}_{testname} ----"); } public function afterSuite() { if (!$this->webDriverModule) { return; } $links = ''; if (count($this->slides)) { foreach ($this->recordedTests as $suiteName => $suite) { $links .= "
  • {$suiteName}
    • "; foreach ($suite as $fileName => $tests) { $links .= "
    • {$fileName}
      • "; foreach ($tests as $test) { $links .= in_array($test['path'], $this->skipRecording, true) ? "
      • config['error_color']}\">{$test['name']}
      • \n" : '
      • {$test['name']}
      • \n"; } $links .= '
      '; } $links .= '
'; } $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: {$exception->getMessage()}" ); } $this->writeln('⏺ Records saved into: file://' . codecept_output_dir() . 'records.html'); } 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: {$this->dir}" ); } } /** * @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: {$dir}" ); } $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 {$testPath}/before" ); } } 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 {$testPath}: " . "{$exception->getMessage()}" ); } $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 {$testPath}/{$e->getStep()->getAction()}" ); } $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] ); } }