VisualCeption.php 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491
  1. <?php
  2. namespace Codeception\Module;
  3. use Codeception\Module as CodeceptionModule;
  4. use Codeception\Test\Descriptor;
  5. use RemoteWebDriver;
  6. /**
  7. * Class VisualCeption
  8. *
  9. * @copyright Copyright (c) 2014 G+J Digital Products GmbH
  10. * @license MIT license, http://www.opensource.org/licenses/mit-license.php
  11. * @package Codeception\Module
  12. *
  13. * @author Nils Langner <langner.nils@guj.de>
  14. * @author Torsten Franz
  15. * @author Sebastian Neubert
  16. */
  17. class VisualCeption extends CodeceptionModule
  18. {
  19. protected $config = [
  20. 'maximumDeviation' => 0,
  21. 'saveCurrentImageIfFailure' => true,
  22. 'referenceImageDir' => 'VisualCeption/',
  23. 'currentImageDir' => 'debug/visual/',
  24. 'report' => false,
  25. 'module' => 'WebDriver'
  26. ];
  27. protected $saveCurrentImageIfFailure;
  28. private $referenceImageDir;
  29. /**
  30. * This var represents the directory where the taken images are stored
  31. * @var string
  32. */
  33. private $currentImageDir;
  34. private $maximumDeviation = 0;
  35. /**
  36. * @var RemoteWebDriver
  37. */
  38. private $webDriver = null;
  39. /**
  40. * @var WebDriver
  41. */
  42. private $webDriverModule = null;
  43. private $failed = array();
  44. private $logFile;
  45. private $templateVars = array();
  46. private $templateFile;
  47. public function _initialize()
  48. {
  49. $this->maximumDeviation = $this->config["maximumDeviation"];
  50. $this->saveCurrentImageIfFailure = (boolean)$this->config["saveCurrentImageIfFailure"];
  51. $this->referenceImageDir = codecept_data_dir() . $this->config["referenceImageDir"];
  52. if (!is_dir($this->referenceImageDir)) {
  53. $this->debug("Creating directory: $this->referenceImageDir");
  54. @mkdir($this->referenceImageDir, 0777, true);
  55. }
  56. $this->currentImageDir = codecept_output_dir() . $this->config["currentImageDir"];
  57. $this->_initVisualReport();
  58. }
  59. public function _afterSuite()
  60. {
  61. if (!$this->config['report']) {
  62. return;
  63. }
  64. $failedTests = $this->failed;
  65. $vars = $this->templateVars;
  66. $referenceImageDir = $this->referenceImageDir;
  67. $i = 0;
  68. ob_start();
  69. include_once $this->templateFile;
  70. $reportContent = ob_get_contents();
  71. ob_clean();
  72. $this->debug("Trying to store file (".$this->logFile.")");
  73. file_put_contents($this->logFile, $reportContent);
  74. }
  75. public function _failed(\Codeception\TestInterface $test, $fail)
  76. {
  77. if ($fail instanceof ImageDeviationException) {
  78. $this->failed[Descriptor::getTestAsString($test)] = $fail;
  79. }
  80. }
  81. /**
  82. * Event hook before a test starts
  83. *
  84. * @param \Codeception\TestInterface $test
  85. * @throws \Exception
  86. */
  87. public function _before(\Codeception\TestInterface $test)
  88. {
  89. if (!$this->hasModule($this->config['module'])) {
  90. throw new \Codeception\Exception\ConfigurationException("VisualCeption uses the WebDriver. Please ensure that this module is activated.");
  91. }
  92. if (!class_exists('Imagick')) {
  93. throw new \Codeception\Exception\ConfigurationException("VisualCeption requires ImageMagick PHP Extension but it was not installed");
  94. }
  95. $this->webDriverModule = $this->getModule($this->config['module']);
  96. $this->webDriver = $this->webDriverModule->webDriver;
  97. if ($this->webDriver->executeScript('return !window.jQuery;')) {
  98. $jQueryString = file_get_contents(__DIR__ . "/jquery.js");
  99. $this->webDriver->executeScript($jQueryString);
  100. $this->webDriver->executeScript('jQuery.noConflict();');
  101. }
  102. $this->test = $test;
  103. }
  104. /**
  105. * Compare the reference image with a current screenshot, identified by their indentifier name
  106. * and their element ID.
  107. *
  108. * @param string $identifier Identifies your test object
  109. * @param string $elementID DOM ID of the element, which should be screenshotted
  110. * @param string|array $excludeElements Element name or array of Element names, which should not appear in the screenshot
  111. * @param float $deviation
  112. */
  113. public function seeVisualChanges($identifier, $elementID = null, $excludeElements = array(), $deviation = null)
  114. {
  115. $excludeElements = (array)$excludeElements;
  116. if (!$deviation && !is_numeric($deviation)) {
  117. $deviation = (float)$this->maximumDeviation;
  118. }
  119. $deviationResult = $this->getDeviation($identifier, $elementID, $excludeElements);
  120. if (is_null($deviationResult["deviationImage"])) {
  121. return;
  122. }
  123. if ($deviationResult["deviation"] <= $deviation) {
  124. $compareScreenshotPath = $this->getDeviationScreenshotPath($identifier);
  125. $deviationResult["deviationImage"]->writeImage($compareScreenshotPath);
  126. throw new ImageDeviationException("The deviation of the taken screenshot is too low (" . $deviationResult["deviation"] . "%).\nSee $compareScreenshotPath for a deviation screenshot.",
  127. $this->getExpectedScreenshotPath($identifier),
  128. $this->getScreenshotPath($identifier),
  129. $compareScreenshotPath);
  130. }
  131. // used for assertion counter in codeception / phpunit
  132. $this->assertTrue(true);
  133. }
  134. /**
  135. * Compare the reference image with a current screenshot, identified by their indentifier name
  136. * and their element ID.
  137. *
  138. * @param string $identifier identifies your test object
  139. * @param string $elementID DOM ID of the element, which should be screenshotted
  140. * @param string|array $excludeElements string of Element name or array of Element names, which should not appear in the screenshot
  141. * @param float $deviation
  142. */
  143. public function dontSeeVisualChanges($identifier, $elementID = null, $excludeElements = array(), $deviation = null)
  144. {
  145. $excludeElements = (array)$excludeElements;
  146. if (!$deviation && !is_numeric($deviation)) {
  147. $deviation = (float)$this->maximumDeviation;
  148. }
  149. $deviationResult = $this->getDeviation($identifier, $elementID, $excludeElements);
  150. if (is_null($deviationResult["deviationImage"])) {
  151. return;
  152. }
  153. if ($deviationResult["deviation"] > $deviation) {
  154. $compareScreenshotPath = $this->getDeviationScreenshotPath($identifier);
  155. $deviationResult["deviationImage"]->writeImage($compareScreenshotPath);
  156. throw new ImageDeviationException("The deviation of the taken screenshot is too hight (" . $deviationResult["deviation"] . "%).\nSee $compareScreenshotPath for a deviation screenshot.",
  157. $this->getExpectedScreenshotPath($identifier),
  158. $this->getScreenshotPath($identifier),
  159. $compareScreenshotPath);
  160. }
  161. // used for assertion counter in codeception / phpunit
  162. $this->assertTrue(true);
  163. }
  164. /**
  165. * Hide an element to set the visibility to hidden
  166. *
  167. * @param $elementSelector String of jQuery Element selector, set visibility to hidden
  168. */
  169. private function hideElement($elementSelector)
  170. {
  171. $this->webDriver->executeScript('
  172. if( jQuery("' . $elementSelector . '").length > 0 ) {
  173. jQuery( "' . $elementSelector . '" ).css("visibility","hidden");
  174. }
  175. ');
  176. $this->debug("set visibility of element '$elementSelector' to 'hidden'");
  177. }
  178. /**
  179. * Show an element to set the visibility to visible
  180. *
  181. * @param $elementSelector String of jQuery Element selector, set visibility to visible
  182. */
  183. private function showElement($elementSelector)
  184. {
  185. $this->webDriver->executeScript('
  186. if( jQuery("' . $elementSelector . '").length > 0 ) {
  187. jQuery( "' . $elementSelector . '" ).css("visibility","visible");
  188. }
  189. ');
  190. $this->debug("set visibility of element '$elementSelector' to 'visible'");
  191. }
  192. /**
  193. * Compares the two images and calculate the deviation between expected and actual image
  194. *
  195. * @param string $identifier Identifies your test object
  196. * @param string $elementID DOM ID of the element, which should be screenshotted
  197. * @param array $excludeElements Element names, which should not appear in the screenshot
  198. * @return array Includes the calculation of deviation in percent and the diff-image
  199. */
  200. private function getDeviation($identifier, $elementID, array $excludeElements = array())
  201. {
  202. $coords = $this->getCoordinates($elementID);
  203. $this->createScreenshot($identifier, $coords, $excludeElements);
  204. $compareResult = $this->compare($identifier);
  205. $deviation = $compareResult[1] * 100;
  206. $this->debug("The deviation between the images is ". $deviation . " percent");
  207. return array (
  208. "deviation" => $deviation,
  209. "deviationImage" => $compareResult[0],
  210. "currentImage" => $compareResult['currentImage'],
  211. );
  212. }
  213. /**
  214. * Initialize the module and read the config.
  215. * Throws a runtime exception, if the
  216. * reference image dir is not set in the config
  217. *
  218. * @throws \RuntimeException
  219. */
  220. /**
  221. * Find the position and proportion of a DOM element, specified by it's ID.
  222. * The method inject the
  223. * JQuery Framework and uses the "noConflict"-mode to get the width, height and offset params.
  224. *
  225. * @param string $elementId DOM ID of the element, which should be screenshotted
  226. * @return array coordinates of the element
  227. */
  228. private function getCoordinates($elementId)
  229. {
  230. if (is_null($elementId)) {
  231. $elementId = 'body';
  232. }
  233. if ($this->webDriver->executeScript('return !window.jQuery;')) {
  234. $jQueryString = file_get_contents(__DIR__ . "/jquery.js");
  235. $this->webDriver->executeScript($jQueryString);
  236. $this->webDriver->executeScript('jQuery.noConflict();');
  237. }
  238. $imageCoords = array();
  239. $elementExists = (bool)$this->webDriver->executeScript('return jQuery( "' . $elementId . '" ).length > 0;');
  240. if (!$elementExists) {
  241. throw new \Exception("The element you want to examine ('" . $elementId . "') was not found.");
  242. }
  243. $imageCoords['offset_x'] = (string)$this->webDriver->executeScript('return jQuery( "' . $elementId . '" ).offset().left;');
  244. $imageCoords['offset_y'] = (string)$this->webDriver->executeScript('return jQuery( "' . $elementId . '" ).offset().top;');
  245. $imageCoords['width'] = (string)$this->webDriver->executeScript('return jQuery( "' . $elementId . '" ).width() * window.devicePixelRatio;');
  246. $imageCoords['height'] = (string)$this->webDriver->executeScript('return jQuery( "' . $elementId . '" ).height() * window.devicePixelRatio;');
  247. return $imageCoords;
  248. }
  249. /**
  250. * Generates a screenshot image filename
  251. * it uses the testcase name and the given indentifier to generate a png image name
  252. *
  253. * @param string $identifier identifies your test object
  254. * @return string Name of the image file
  255. */
  256. private function getScreenshotName($identifier)
  257. {
  258. $signature = $this->test->getSignature();
  259. return str_replace(':', '_', $signature). '.' . $identifier . '.png';
  260. }
  261. /**
  262. * Returns the temporary path including the filename where a the screenshot should be saved
  263. * If the path doesn't exist, the method generate it itself
  264. *
  265. * @param string $identifier identifies your test object
  266. * @return string Path an name of the image file
  267. * @throws \RuntimeException if debug dir could not create
  268. */
  269. private function getScreenshotPath($identifier)
  270. {
  271. $debugDir = $this->currentImageDir;
  272. if (!is_dir($debugDir)) {
  273. $created = @mkdir($debugDir, 0777, true);
  274. if ($created) {
  275. $this->debug("Creating directory: $debugDir");
  276. } else {
  277. throw new \RuntimeException("Unable to create temporary screenshot dir ($debugDir)");
  278. }
  279. }
  280. return $debugDir . $this->getScreenshotName($identifier);
  281. }
  282. /**
  283. * Returns the reference image path including the filename
  284. *
  285. * @param string $identifier identifies your test object
  286. * @return string Name of the reference image file
  287. */
  288. private function getExpectedScreenshotPath($identifier)
  289. {
  290. return $this->referenceImageDir . $this->getScreenshotName($identifier);
  291. }
  292. /**
  293. * Generate the screenshot of the dom element
  294. *
  295. * @param string $identifier identifies your test object
  296. * @param array $coords Coordinates where the DOM element is located
  297. * @param array $excludeElements List of elements, which should not appear in the screenshot
  298. * @return string Path of the current screenshot image
  299. */
  300. private function createScreenshot($identifier, array $coords, array $excludeElements = array())
  301. {
  302. $screenShotDir = \Codeception\Configuration::logDir() . 'debug/';
  303. if( !is_dir($screenShotDir)) {
  304. mkdir($screenShotDir, 0777, true);
  305. }
  306. $screenshotPath = $screenShotDir . 'fullscreenshot.tmp.png';
  307. $elementPath = $this->getScreenshotPath($identifier);
  308. $this->hideElementsForScreenshot($excludeElements);
  309. $this->webDriver->takeScreenshot($screenshotPath);
  310. $this->resetHideElementsForScreenshot($excludeElements);
  311. $screenShotImage = new \Imagick();
  312. $screenShotImage->readImage($screenshotPath);
  313. $screenShotImage->cropImage($coords['width'], $coords['height'], $coords['offset_x'], $coords['offset_y']);
  314. $screenShotImage->writeImage($elementPath);
  315. unlink($screenshotPath);
  316. return $elementPath;
  317. }
  318. /**
  319. * Hide the given elements with CSS visibility = hidden. Wait a second after hiding
  320. *
  321. * @param array $excludeElements Array of strings, which should be not visible
  322. */
  323. private function hideElementsForScreenshot(array $excludeElements)
  324. {
  325. foreach ($excludeElements as $element) {
  326. $this->hideElement($element);
  327. }
  328. $this->webDriverModule->wait(1);
  329. }
  330. /**
  331. * Reset hiding the given elements with CSS visibility = visible. Wait a second after reset hiding
  332. *
  333. * @param array $excludeElements array of strings, which should be visible again
  334. */
  335. private function resetHideElementsForScreenshot(array $excludeElements)
  336. {
  337. foreach ($excludeElements as $element) {
  338. $this->showElement($element);
  339. }
  340. $this->webDriverModule->wait(1);
  341. }
  342. /**
  343. * Returns the image path including the filename of a deviation image
  344. *
  345. * @param $identifier identifies your test object
  346. * @return string Path of the deviation image
  347. */
  348. private function getDeviationScreenshotPath ($identifier, $alternativePrefix = '')
  349. {
  350. $debugDir = \Codeception\Configuration::logDir() . 'debug/';
  351. $prefix = ( $alternativePrefix === '') ? 'compare' : $alternativePrefix;
  352. return $debugDir . $prefix . $this->getScreenshotName($identifier);
  353. }
  354. /**
  355. * Compare two images by its identifiers.
  356. * If the reference image doesn't exists
  357. * the image is copied to the reference path.
  358. *
  359. * @param $identifier identifies your test object
  360. * @return array Test result of image comparison
  361. */
  362. private function compare($identifier)
  363. {
  364. $expectedImagePath = $this->getExpectedScreenshotPath($identifier);
  365. $currentImagePath = $this->getScreenshotPath($identifier);
  366. if (!file_exists($expectedImagePath)) {
  367. $this->debug("Copying image (from $currentImagePath to $expectedImagePath");
  368. copy($currentImagePath, $expectedImagePath);
  369. return array (null, 0, 'currentImage' => null);
  370. } else {
  371. return $this->compareImages($expectedImagePath, $currentImagePath);
  372. }
  373. }
  374. /**
  375. * Compares to images by given file path
  376. *
  377. * @param $image1 Path to the exprected reference image
  378. * @param $image2 Path to the current image in the screenshot
  379. * @return array Result of the comparison
  380. */
  381. private function compareImages($image1, $image2)
  382. {
  383. $this->debug("Trying to compare $image1 with $image2");
  384. $imagick1 = new \Imagick($image1);
  385. $imagick2 = new \Imagick($image2);
  386. $imagick1Size = $imagick1->getImageGeometry();
  387. $imagick2Size = $imagick2->getImageGeometry();
  388. $maxWidth = max($imagick1Size['width'], $imagick2Size['width']);
  389. $maxHeight = max($imagick1Size['height'], $imagick2Size['height']);
  390. $imagick1->extentImage($maxWidth, $maxHeight, 0, 0);
  391. $imagick2->extentImage($maxWidth, $maxHeight, 0, 0);
  392. try {
  393. $result = $imagick1->compareImages($imagick2, \Imagick::METRIC_MEANSQUAREERROR);
  394. $result[0]->setImageFormat('png');
  395. $result['currentImage'] = clone $imagick2;
  396. $result['currentImage']->setImageFormat('png');
  397. }
  398. catch (\ImagickException $e) {
  399. $this->debug("IMagickException! could not campare image1 ($image1) and image2 ($image2).\nExceptionMessage: " . $e->getMessage());
  400. $this->fail($e->getMessage() . ", image1 $image1 and image2 $image2.");
  401. }
  402. return $result;
  403. }
  404. protected function _initVisualReport()
  405. {
  406. if (!$this->config['report']) {
  407. return;
  408. }
  409. $this->logFile = \Codeception\Configuration::logDir() . 'vcresult.html';
  410. if (array_key_exists('templateVars', $this->config)) {
  411. $this->templateVars = $this->config["templateVars"];
  412. }
  413. if (array_key_exists('templateFile', $this->config)) {
  414. $this->templateFile = $this->config["templateFile"];
  415. } else {
  416. $this->templateFile = __DIR__ . "/report/template.php";
  417. }
  418. $this->debug( "VisualCeptionReporter: templateFile = " . $this->templateFile );
  419. }
  420. }