helper.js 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256
  1. /**
  2. * @copyright 2018 Julius Härtl <jus@bitgrid.net>
  3. *
  4. * @author 2018 Julius Härtl <jus@bitgrid.net>
  5. *
  6. * @license GNU AGPL version 3 or any later version
  7. *
  8. * This program is free software: you can redistribute it and/or modify
  9. * it under the terms of the GNU Affero General Public License as
  10. * published by the Free Software Foundation, either version 3 of the
  11. * License, or (at your option) any later version.
  12. *
  13. * This program is distributed in the hope that it will be useful,
  14. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  15. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  16. * GNU Affero General Public License for more details.
  17. *
  18. * You should have received a copy of the GNU Affero General Public License
  19. * along with this program. If not, see <http://www.gnu.org/licenses/>.
  20. *
  21. */
  22. const puppeteer = require('puppeteer');
  23. const pixelmatch = require('pixelmatch');
  24. const expect = require('chai').expect;
  25. const PNG = require('pngjs2').PNG;
  26. const fs = require('fs');
  27. const config = require('./config.js');
  28. module.exports = {
  29. browser: null,
  30. pageBase: null,
  31. pageCompare: null,
  32. lastBase: 0,
  33. lastCompare: 0,
  34. init: async function (test) {
  35. this._outputDirectory = `${config.outputDirectory}/${test.title}`;
  36. if (!fs.existsSync(config.outputDirectory)) fs.mkdirSync(config.outputDirectory);
  37. if (!fs.existsSync(this._outputDirectory)) fs.mkdirSync(this._outputDirectory);
  38. await this.resetBrowser();
  39. },
  40. exit: async function () {
  41. await this.browser.close();
  42. },
  43. resetBrowser: async function () {
  44. if (this.browser) {
  45. await this.browser.close();
  46. }
  47. this.browser = await puppeteer.launch({
  48. args: ['--no-sandbox', '--disable-setuid-sandbox'],
  49. headless: config.headless,
  50. slowMo: config.slowMo,
  51. });
  52. this.pageBase = await this.browser.newPage();
  53. this.pageCompare = await this.browser.newPage();
  54. this.pageBase.setDefaultNavigationTimeout(60000);
  55. this.pageCompare.setDefaultNavigationTimeout(60000);
  56. const self = this;
  57. this.pageCompare.on('requestfinished', function() {
  58. self.lastCompare = Date.now();
  59. });
  60. this.pageBase.on('requestfinished', function() {
  61. self.lastBase = Date.now();
  62. });
  63. },
  64. awaitNetworkIdle: async function (seconds) {
  65. var self = this;
  66. return new Promise(function (resolve, reject) {
  67. const timeout = setTimeout(function() {
  68. reject();
  69. }, 10000)
  70. const waitForFoo = function() {
  71. const currentTime = Date.now() - seconds*1000;
  72. if (self.lastBase < currentTime && self.lastCompare < currentTime) {
  73. clearTimeout(timeout);
  74. return resolve();
  75. }
  76. setTimeout(waitForFoo, 100);
  77. };
  78. waitForFoo();
  79. });
  80. },
  81. login: async function (test) {
  82. test.timeout(20000);
  83. await this.resetBrowser();
  84. await Promise.all([
  85. this.performLogin(this.pageBase, config.urlBase),
  86. this.performLogin(this.pageCompare, config.urlChange)
  87. ]);
  88. },
  89. performLogin: async function (page, baseUrl) {
  90. await page.bringToFront();
  91. await page.goto(baseUrl + '/index.php/login', {waitUntil: 'networkidle0'});
  92. await page.type('#user', 'admin');
  93. await page.type('#password', 'admin');
  94. const inputElement = await page.$('input[type=submit]');
  95. await inputElement.click();
  96. await page.waitForNavigation({waitUntil: 'networkidle2'});
  97. return await page.waitForSelector('#header');
  98. },
  99. takeAndCompare: async function (test, route, action, options) {
  100. // use Promise.all
  101. if (options === undefined)
  102. options = {};
  103. if (options.waitUntil === undefined) {
  104. options.waitUntil = 'networkidle0';
  105. }
  106. if (options.viewport) {
  107. if (options.viewport.scale === undefined) {
  108. options.viewport.scale = 1;
  109. }
  110. await Promise.all([
  111. this.pageBase.setViewport({
  112. width: options.viewport.w,
  113. height: options.viewport.h,
  114. deviceScaleFactor: options.viewport.scale
  115. }),
  116. this.pageCompare.setViewport({
  117. width: options.viewport.w,
  118. height: options.viewport.h,
  119. deviceScaleFactor: options.viewport.scale
  120. })
  121. ]);
  122. await this.delay(100);
  123. }
  124. let fileName = test.test.title
  125. if (route !== undefined) {
  126. await Promise.all([
  127. this.pageBase.goto(`${config.urlBase}${route}`, {waitUntil: options.waitUntil}),
  128. this.pageCompare.goto(`${config.urlChange}${route}`, {waitUntil: options.waitUntil})
  129. ]);
  130. }
  131. await this.pageBase.$eval('body', function (e) {
  132. $('.live-relative-timestamp').removeClass('live-relative-timestamp').text('5 minutes ago');
  133. $(':focus').blur();
  134. });
  135. await this.pageCompare.$eval('body', function (e) {
  136. $('.live-relative-timestamp').removeClass('live-relative-timestamp').text('5 minutes ago');
  137. $(':focus').blur();
  138. });
  139. var failed = null;
  140. try {
  141. await this.pageBase.bringToFront();
  142. await action(this.pageBase);
  143. await this.pageCompare.bringToFront();
  144. await action(this.pageCompare);
  145. } catch (err) {
  146. failed = err;
  147. }
  148. await this.awaitNetworkIdle(3);
  149. await this.pageBase.$eval('body', function (e) {
  150. $('.live-relative-timestamp').removeClass('live-relative-timestamp').text('5 minutes ago');
  151. $(':focus').blur();
  152. });
  153. await this.pageCompare.$eval('body', function (e) {
  154. $('.live-relative-timestamp').removeClass('live-relative-timestamp').text('5 minutes ago');
  155. $(':focus').blur();
  156. });
  157. await Promise.all([
  158. this.pageBase.screenshot({
  159. path: `${this._outputDirectory}/${fileName}.base.png`,
  160. fullPage: false,
  161. }),
  162. this.pageCompare.screenshot({
  163. path: `${this._outputDirectory}/${fileName}.change.png`,
  164. fullPage: false
  165. })
  166. ]);
  167. if (options.runOnly === true) {
  168. fs.unlinkSync(`${this._outputDirectory}/${fileName}.base.png`);
  169. fs.renameSync(`${this._outputDirectory}/${fileName}.change.png`, `${this._outputDirectory}/${fileName}.png`);
  170. }
  171. return new Promise(async (resolve, reject) => {
  172. try {
  173. if (options.runOnly !== true) {
  174. await this.compareScreenshots(fileName);
  175. }
  176. } catch (err) {
  177. if (failed) {
  178. console.log('Failure during takeAndCompare action callback');
  179. console.log(failed);
  180. }
  181. console.log('Failure when comparing images');
  182. return reject(err);
  183. }
  184. if (options.runOnly !== true && failed) {
  185. console.log('Failure during takeAndCompare action callback');
  186. console.log(failed);
  187. failed.failedAction = true;
  188. return reject(failed);
  189. }
  190. return resolve();
  191. });
  192. },
  193. compareScreenshots: function (fileName) {
  194. let self = this;
  195. return new Promise((resolve, reject) => {
  196. const img1 = fs.createReadStream(`${self._outputDirectory}/${fileName}.base.png`).pipe(new PNG()).on('parsed', doneReading);
  197. const img2 = fs.createReadStream(`${self._outputDirectory}/${fileName}.change.png`).pipe(new PNG()).on('parsed', doneReading);
  198. let filesRead = 0;
  199. function doneReading () {
  200. // Wait until both files are read.
  201. if (++filesRead < 2) return;
  202. // The files should be the same size.
  203. expect(img1.width, 'image widths are the same').equal(img2.width);
  204. expect(img1.height, 'image heights are the same').equal(img2.height);
  205. // Do the visual diff.
  206. const diff = new PNG({width: img1.width, height: img2.height});
  207. const numDiffPixels = pixelmatch(
  208. img1.data, img2.data, diff.data, img1.width, img1.height,
  209. {threshold: 0.3});
  210. if (numDiffPixels > 0) {
  211. diff.pack().pipe(fs.createWriteStream(`${self._outputDirectory}/${fileName}.diff.png`));
  212. } else {
  213. fs.unlinkSync(`${self._outputDirectory}/${fileName}.base.png`);
  214. fs.renameSync(`${self._outputDirectory}/${fileName}.change.png`, `${self._outputDirectory}/${fileName}.png`);
  215. }
  216. // The files should look the same.
  217. expect(numDiffPixels, 'number of different pixels').equal(0);
  218. resolve();
  219. }
  220. });
  221. },
  222. /**
  223. * Helper function to wait
  224. * to make sure that initial animations are done
  225. */
  226. delay: async function (timeout) {
  227. return new Promise((resolve) => {
  228. setTimeout(resolve, timeout);
  229. });
  230. },
  231. childOfClassByText: async function (page, classname, text) {
  232. return page.$x('//*[contains(concat(" ", normalize-space(@class), " "), " ' + classname + ' ")]//text()[normalize-space() = \'' + text + '\']/..');
  233. },
  234. childOfIdByText: async function (page, classname, text) {
  235. return page.$x('//*[contains(concat(" ", normalize-space(@id), " "), " ' + classname + ' ")]//text()[normalize-space() = \'' + text + '\']/..');
  236. }
  237. };