login(); * $I->amOnUrl('https://codeception.com'); * } * ``` * */ class Recorder extends Extension { protected array $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, 'include_microseconds' => false, ]; protected string $template = << Recorder Result EOF; protected string $indicatorTemplate = << EOF; protected string $indexTemplate = << Recorder Results Index

Record #{{seed}}

    {{records}}
EOF; protected string $slidesTemplate = << EOF; public static array $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', ]; protected ?\Codeception\Module $webDriverModule = null; protected ?string $dir = null; protected array $slides = []; protected int $stepNum = 0; protected ?string $seed = null; protected array $seeds = []; protected array $recordedTests = []; protected array $skipRecording = []; protected array $errorMessages = []; protected bool $colors = false; protected bool $ansi = false; protected array $timeStamps = []; private ?string $dateFormat = null; public function beforeSuite(): void { $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->dateFormat = $this->config['include_microseconds'] ? 'Y-m-d\TH:i:s.uP' : DATE_ATOM; $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(): void { if (!$this->webDriverModule) { return; } $links = ''; if (!empty($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); } } public function before(TestEvent $event): void { if (!$this->webDriverModule) { return; } $this->dir = null; $this->stepNum = 0; $this->slides = []; $this->timeStamps = []; $this->dir = codecept_output_dir() . "record_{$this->seed}_{$this->getTestName($event)}"; $testPath = codecept_relative_path(Descriptor::getTestFullName($event->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}" ); } } public function cleanup(TestEvent $event): void { 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($event); return; } // deleting successfully executed tests FileSystem::deleteDir($this->dir); } public function persist(TestEvent $event): void { if (!$this->webDriverModule) { return; } $indicatorHtml = ''; $slideHtml = ''; $testName = $this->getTestName($event); $testPath = codecept_relative_path(Descriptor::getTestFullName($event->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->timeStamps = []; $this->slides[$filename] = new Step\Action('encountered an unexpected error prior to the test execution'); $this->timeStamps[$filename] = (new DateTime())->format($this->dateFormat); $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) { /** @var Step $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') ->place('timeStamp', $this->timeStamps[$i]) ->produce(); } $html = (new Template($this->template)) ->place('indicators', $indicatorHtml) ->place('slides', $slideHtml) ->place('feature', ucfirst($event->getTest()->getFeature())) ->place('test', Descriptor::getTestSignature($event->getTest())) ->place('carousel_class', $this->config['animate_slides'] ? ' slide' : '') ->produce(); $indexFile = $this->dir . DIRECTORY_SEPARATOR . 'index.html'; $environment = $event->getTest()->getMetadata()->getCurrent('env') ?: ''; $suite = ucfirst(basename(dirname($event->getTest()->getMetadata()->getFilename()))); $testName = basename($event->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' => $event->getTest()->getMetadata()->getName(), 'path' => $testPath, 'status' => $status, 'index' => substr($indexFile, strlen(codecept_output_dir())), ]; } } public function afterStep(StepEvent $event): void { if ($this->webDriverModule === null || $this->dir === null) { return; } if ($event->getStep() instanceof CommentStep) { return; } // only taking the ignore step into consideration if that step has passed if ($this->isStepIgnored($event) && !$event->getStep()->hasFailed()) { return; } $filename = str_pad((string)$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($event->getTest())); $this->appendErrorMessage( $testPath, "⏺ Unable to capture a screenshot for {$testPath}/{$event->getStep()->getAction()}" ); } ++$this->stepNum; $this->slides[$filename] = $event->getStep(); $this->timeStamps[$filename] = (new DateTime())->format($this->dateFormat); } protected function isStepIgnored(StepEvent $event): bool { $configIgnoredSteps = $this->config['ignore_steps']; $annotationIgnoredSteps = $event->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, $event->getStep()->getAction())) { return true; } if ( $event->getStep()->getMetaStep() !== null && preg_match($stepRegexp, $event->getStep()->getMetaStep()->getAction()) ) { return true; } } return false; } /** * @param StepEvent|TestEvent $event */ private function getTestName(Event $event): string { return basename($event->getTest()->getMetadata()->getFilename()) . '_' . preg_replace('/[^A-Za-z0-9\-\_]/', '_', $event->getTest()->getMetadata()->getName()); } protected function writeln(iterable|string $messages): void { parent::writeln( $this->ansi ? $messages : trim(preg_replace('/[ ]{2,}/', ' ', str_replace('⏺', '', $messages))) ); } private function appendErrorMessage(string $testPath, string $message): void { $this->errorMessages[$testPath] = array_merge( array_key_exists($testPath, $this->errorMessages) ? $this->errorMessages[$testPath] : [], [$message] ); } }