diff --git a/package-lock.json b/package-lock.json index abf9a70d..80e8b930 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1415,6 +1415,69 @@ "kuler": "^2.0.0" } }, + "@ffmpeg-installer/darwin-arm64": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@ffmpeg-installer/darwin-arm64/-/darwin-arm64-4.1.5.tgz", + "integrity": "sha512-hYqTiP63mXz7wSQfuqfFwfLOfwwFChUedeCVKkBtl/cliaTM7/ePI9bVzfZ2c+dWu3TqCwLDRWNSJ5pqZl8otA==", + "optional": true + }, + "@ffmpeg-installer/darwin-x64": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@ffmpeg-installer/darwin-x64/-/darwin-x64-4.1.0.tgz", + "integrity": "sha512-Z4EyG3cIFjdhlY8wI9aLUXuH8nVt7E9SlMVZtWvSPnm2sm37/yC2CwjUzyCQbJbySnef1tQwGG2Sx+uWhd9IAw==", + "optional": true + }, + "@ffmpeg-installer/ffmpeg": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@ffmpeg-installer/ffmpeg/-/ffmpeg-1.1.0.tgz", + "integrity": "sha512-Uq4rmwkdGxIa9A6Bd/VqqYbT7zqh1GrT5/rFwCwKM70b42W5gIjWeVETq6SdcL0zXqDtY081Ws/iJWhr1+xvQg==", + "requires": { + "@ffmpeg-installer/darwin-arm64": "4.1.5", + "@ffmpeg-installer/darwin-x64": "4.1.0", + "@ffmpeg-installer/linux-arm": "4.1.3", + "@ffmpeg-installer/linux-arm64": "4.1.4", + "@ffmpeg-installer/linux-ia32": "4.1.0", + "@ffmpeg-installer/linux-x64": "4.1.0", + "@ffmpeg-installer/win32-ia32": "4.1.0", + "@ffmpeg-installer/win32-x64": "4.1.0" + } + }, + "@ffmpeg-installer/linux-arm": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@ffmpeg-installer/linux-arm/-/linux-arm-4.1.3.tgz", + "integrity": "sha512-NDf5V6l8AfzZ8WzUGZ5mV8O/xMzRag2ETR6+TlGIsMHp81agx51cqpPItXPib/nAZYmo55Bl2L6/WOMI3A5YRg==", + "optional": true + }, + "@ffmpeg-installer/linux-arm64": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@ffmpeg-installer/linux-arm64/-/linux-arm64-4.1.4.tgz", + "integrity": "sha512-dljEqAOD0oIM6O6DxBW9US/FkvqvQwgJ2lGHOwHDDwu/pX8+V0YsDL1xqHbj1DMX/+nP9rxw7G7gcUvGspSoKg==", + "optional": true + }, + "@ffmpeg-installer/linux-ia32": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@ffmpeg-installer/linux-ia32/-/linux-ia32-4.1.0.tgz", + "integrity": "sha512-0LWyFQnPf+Ij9GQGD034hS6A90URNu9HCtQ5cTqo5MxOEc7Rd8gLXrJvn++UmxhU0J5RyRE9KRYstdCVUjkNOQ==", + "optional": true + }, + "@ffmpeg-installer/linux-x64": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@ffmpeg-installer/linux-x64/-/linux-x64-4.1.0.tgz", + "integrity": "sha512-Y5BWhGLU/WpQjOArNIgXD3z5mxxdV8c41C+U15nsE5yF8tVcdCGet5zPs5Zy3Ta6bU7haGpIzryutqCGQA/W8A==", + "optional": true + }, + "@ffmpeg-installer/win32-ia32": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@ffmpeg-installer/win32-ia32/-/win32-ia32-4.1.0.tgz", + "integrity": "sha512-FV2D7RlaZv/lrtdhaQ4oETwoFUsUjlUiasiZLDxhEUPdNDWcH1OU9K1xTvqz+OXLdsmYelUDuBS/zkMOTtlUAw==", + "optional": true + }, + "@ffmpeg-installer/win32-x64": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@ffmpeg-installer/win32-x64/-/win32-x64-4.1.0.tgz", + "integrity": "sha512-Drt5u2vzDnIONf4ZEkKtFlbvwj6rI3kxw1Ck9fpudmtgaZIHD4ucsWB2lCZBXRxJgXR+2IMSti+4rtM4C4rXgg==", + "optional": true + }, "@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", diff --git a/package.json b/package.json index 7026971f..baf97fe6 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ }, "dependencies": { "@applitools/eyes-webdriverio": "5.31.9", + "@ffmpeg-installer/ffmpeg": "^1.1.0", "@lambdatest/node-rest-client": "1.0.0", "@oxygenhq/chrome-remote-interface": "0.30.0", "@oxygenhq/logger": "0.5.1", diff --git a/src/core/OxygenCore.js b/src/core/OxygenCore.js index c41ca8fc..2f191598 100644 --- a/src/core/OxygenCore.js +++ b/src/core/OxygenCore.js @@ -10,6 +10,7 @@ import StepResult from '../model/step-result'; import OxygenEvents from './OxygenEvents'; import oxutil from '../lib/util'; import * as coreUtils from './utils'; +import { commandsWhichShouldBeExcludedTakeScreenshot } from './constants'; import OxError from '../errors/OxygenError'; import errorHelper from '../errors/helper'; import STATUS from '../model/status.js'; @@ -64,7 +65,8 @@ const DEFAULT_CTX = { const DEFAULT_RESULT_STORE = { steps: [], logs: [], - har: null + har: null, + video: null }; export default class Oxygen extends OxygenEvents { @@ -205,6 +207,7 @@ export default class Oxygen extends OxygenEvents { this.resultStore.steps = []; this.resultStore.logs = []; this.har = null; + this.video = null; } async onBeforeCase(context) { @@ -216,6 +219,7 @@ export default class Oxygen extends OxygenEvents { try { module.onBeforeCase && await module.onBeforeCase(context); module._iterationStart && await module._iterationStart(); + await this._callServicesonBeforeCase(context, module); } catch (e) { this.logger.error(`Failed to call "onBeforeCase" method of ${moduleName} module.`, e); @@ -233,6 +237,7 @@ export default class Oxygen extends OxygenEvents { // await for avoid stuck on *.dispose call module.onAfterCase && await module.onAfterCase(error); module._iterationEnd && await module._iterationEnd(error); + await this._callServicesonAfterCase(module); } catch (e) { this.logger.error(`Failed to call "onAfterCase" method of ${moduleName} module.`, e); @@ -782,6 +787,7 @@ export default class Oxygen extends OxygenEvents { step.stats = {}; } + let doNotTryTakeScreenshot = false; if (err) { if (err && err.type && err.type === errorHelper.errorCode.ASSERT_PASSED) { //ignore @@ -805,11 +811,27 @@ export default class Oxygen extends OxygenEvents { } catch (e) { // FIXME: indicate to user that an attempt to take a screenshot has failed + doNotTryTakeScreenshot = true; } } } } } + + if (this.opts.video) { + if ( + !doNotTryTakeScreenshot && + ['web'].includes(moduleName) && + commandsWhichShouldBeExcludedTakeScreenshot.includes(methodName) + ) { + try { + module._saveScreenshot(); + } catch (e) { + this.logger.error('takeScreenshotSilent failed:', e); + } + } + } + return step; } @@ -905,6 +927,44 @@ export default class Oxygen extends OxygenEvents { } } } + async _callServicesonBeforeCase(context, module) { + if (!this || !this.services) { + return; + } + for (let serviceName in this.services) { + const service = this.services[serviceName]; + if (!service) { + continue; + } + try { + if (service.onBeforeCase) { + service.onBeforeCase(context, module); + } + } + catch (e) { + this.logger.error(`Failed to call "_callServicesonBeforeCase" method of ${serviceName} service.`, e); + } + } + } + async _callServicesonAfterCase(module) { + if (!this || !this.services) { + return; + } + for (let serviceName in this.services) { + const service = this.services[serviceName]; + if (!service) { + continue; + } + try { + if (service.onAfterCase) { + await service.onAfterCase(module); + } + } + catch (e) { + this.logger.error(`Failed to call "_callServicesonAfterCase" method of ${serviceName} service.`, e); + } + } + } _populateParametersValue(args) { if (!args || !Array.isArray(args) || args.length == 0) { return args; diff --git a/src/core/constants.js b/src/core/constants.js new file mode 100644 index 00000000..bcdd4329 --- /dev/null +++ b/src/core/constants.js @@ -0,0 +1,35 @@ +const webCommands = [ + 'alertAccept', + 'alertDismiss', + 'back', + 'clear', + 'click', + 'clickHidden', + 'closeWindow', + 'deleteCookies', + 'deselect', + 'doubleClick', + 'dragAndDrop', + 'execute', + 'fileBrowse', + 'isVisible', + 'makeVisible', + 'newWindow', + 'open', + 'point', + 'pointJS', + 'refresh', + 'rightClick', + 'rightClickActions', + 'scrollToElement', + 'select', + 'selectFrame', + 'selectWindow', + 'sendKeys', + 'setWindowSize', + 'type' +]; + +export const commandsWhichShouldBeExcludedTakeScreenshot = [ + ...webCommands +]; \ No newline at end of file diff --git a/src/lib/cli-util.js b/src/lib/cli-util.js index 3c658d5a..c14b8703 100644 --- a/src/lib/cli-util.js +++ b/src/lib/cli-util.js @@ -205,6 +205,7 @@ export function getCommandLineOptions(argv) { iterations : argv.i ? parseInt(argv.i) : (argv.iter ? parseInt(argv.iter) : null), debugPort: argv.dbgport || null, delay: argv.d || argv.delay || null, + video: argv.video ? argv.video === 'true' : false }; // switch: --rf flag if (argv.rf && typeof argv.rf === 'string' && argv.rf.length > 0) { diff --git a/src/lib/cli.js b/src/lib/cli.js index def43c54..1b6afb27 100644 --- a/src/lib/cli.js +++ b/src/lib/cli.js @@ -130,6 +130,7 @@ Web test options: -b, --browser={chrome|ie|safari|firefox} Browser name. Default is chrome. -s, --server=SERVER_URL Selenium hub URL. Default is http://localhost:4444/wd/hub. --reopen={true|false} Reopen browser on each iteration. Default is false. + --video={true|false} Enable/Disable video recording Mobile test options: -s, --server=SERVER_URL Appium server URL. Default is http://localhost:4723/wd/hub.`); diff --git a/src/model/case-result.js b/src/model/case-result.js index 5ec2c40e..82b57133 100644 --- a/src/model/case-result.js +++ b/src/model/case-result.js @@ -22,6 +22,7 @@ module.exports = function () { steps: [], // array of step-result.js logs: [], har: null, + video: null, failure: null, context: null }; diff --git a/src/ox_modules/module-web.js b/src/ox_modules/module-web.js index 5be898d4..37370570 100644 --- a/src/ox_modules/module-web.js +++ b/src/ox_modules/module-web.js @@ -58,7 +58,8 @@ import perfectoReporting from 'perfecto-reporting'; import request from 'request'; import mergeImages from '../lib/img-merge'; import errorHelper from '../errors/helper'; - +import fs from 'fs'; +import path from 'path'; const MODULE_NAME = 'web'; const DEFAULT_SELENIUM_URL = 'http://localhost:4444/wd/hub'; const DEFAULT_BROWSER_NAME = 'chrome'; @@ -78,6 +79,7 @@ export default class WebModule extends WebDriverModule { this.errHelper = errHelper; // holds element operation timeout value this.waitForTimeout = DEFAULT_WAIT_TIMEOUT; + this.frameNumber = 0; // for video support } get name() { @@ -563,6 +565,56 @@ export default class WebModule extends WebDriverModule { } } + async _saveScreenshot() { + const recordingPath = path.join(this.options.cwd, 'video'); + this.createFolderIfNotExists(recordingPath); + + const filename = this.frameNumber.toString().padStart(4, '0') + '.png'; + const filePath = path.resolve(recordingPath, filename); + + let error; + try { + if ( + this.driver && + this.driver.saveScreenshot + ) { + let retval; + this.driver.call(() => { + return new Promise((resolve, reject) => { + const waitUntilRetVal = this.driver.waitUntil(async() => { + return await this.driver.saveScreenshot(filePath); + }, + { timeout: 30*1000 }); + + if (waitUntilRetVal && waitUntilRetVal.then) { + waitUntilRetVal.then(() => { + this.frameNumber++; + resolve(); + }).catch((err) => { + reject(err); + }); + } else { + this.frameNumber++; + resolve(); + } + }); + }); + return filePath; + } + } catch (e) { + this.logger.error('Cannot saveScreenshot', e); + } + } + + createFolderIfNotExists(folderPath) { + try { + fs.mkdirSync(folderPath); + } catch (e) { + if ( e.code != 'EEXIST' ) throw e; + } + return folderPath; + } + _adjustBrowserLog(log) { if (!log || typeof log !== 'object') { return null; diff --git a/src/ox_reporters/html/tests-details.ejs b/src/ox_reporters/html/tests-details.ejs index 32d46e75..0ba79d41 100644 --- a/src/ox_reporters/html/tests-details.ejs +++ b/src/ox_reporters/html/tests-details.ejs @@ -52,6 +52,9 @@ <%= caseResult.name %> (Iteration #<%= caseResult.iterationNum %>) <%- caseResult.status === 'passed' ? PASSED_LABEL : FAILED_LABEL %> + <% if (caseResult.video) { %> + 🎥 Video + <% } %>