From 87ab7108d397fc400af66511633d5db1d7508574 Mon Sep 17 00:00:00 2001 From: Savka Oleg Date: Thu, 21 Oct 2021 12:58:43 +0300 Subject: [PATCH] Video recording based on screenshots after each step --- package-lock.json | 63 +++++++++++++++++ package.json | 1 + src/core/OxygenCore.js | 62 ++++++++++++++++- src/core/constants.js | 35 ++++++++++ src/lib/cli-util.js | 1 + src/lib/cli.js | 1 + src/model/case-result.js | 1 + src/ox_modules/module-web.js | 54 ++++++++++++++- src/ox_reporters/html/tests-details.ejs | 3 + src/ox_reporters/reporter-html.js | 1 + src/ox_services/service-video.js | 91 +++++++++++++++++++++++++ src/reporter/FileReporterBase.js | 23 +++++++ src/runners/oxygen/index.js | 1 + 13 files changed, 335 insertions(+), 2 deletions(-) create mode 100644 src/core/constants.js create mode 100644 src/ox_services/service-video.js 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 + <% } %>
diff --git a/src/ox_reporters/reporter-html.js b/src/ox_reporters/reporter-html.js index 5e0496c3..eaab255f 100644 --- a/src/ox_reporters/reporter-html.js +++ b/src/ox_reporters/reporter-html.js @@ -91,6 +91,7 @@ export default class HtmlReporter extends FileReporterBase { const resultFolderPath = path.dirname(resultFilePath); this.replaceScreenshotsWithFiles(results, resultFolderPath); + this.moveVideoToReports(results, resultFolderPath); this.formatTotalDuration(results); const templatePath = path.join(__dirname, '../ox_reporters/html/index.ejs'); diff --git a/src/ox_services/service-video.js b/src/ox_services/service-video.js new file mode 100644 index 00000000..e44e5320 --- /dev/null +++ b/src/ox_services/service-video.js @@ -0,0 +1,91 @@ +import OxygenService from '../core/OxygenService'; +import path from 'path'; +import glob from 'glob'; +import fs from 'fs'; +import { path as ffmpegPath} from '@ffmpeg-installer/ffmpeg'; +import { spawn } from 'child_process'; + +const allowedModules = ['web']; + +export default class VideoService extends OxygenService { + constructor(options, ctx, results, logger) { + super(options, ctx, results, logger); + + this.enabled = options.video || false; + this.videoFolderPath = path.join(options.cwd, 'video'); + this.results = results; + this.logger = logger; + } + logBuffer (buffer, prefix) { + const lines = buffer.toString().trim().split('\n'); + lines.forEach((line) => { + this.logger.debug(prefix + line); + }); + } + onBeforeCase(context, module) { + } + async onAfterCase(module) { + if (this.enabled && allowedModules.includes(module.name)) { + this.results.video = await this._generateVideo(); + } + } + _generateVideo() { + const testname = 'video'+ +new Date(); + const videoPath = path.resolve(this.videoFolderPath, testname + '.mp4'); + const videoSlowdownMultiplier = 30; + + const command = `"${ffmpegPath}"`; + const args = [ + '-y', + '-r', '10', + '-i', `"${this.videoFolderPath}${path.sep}%04d.png"`, + '-vcodec', 'libx264', + '-crf', '32', + '-pix_fmt', 'yuv420p', + '-vf', `"scale=1200:trunc(ow/a/2)*2","setpts=${videoSlowdownMultiplier}.0*PTS"`, + `"${videoPath}"`, + ]; + + console.log(`ffmpeg command: ${command + ' ' + args}\n`); + + const cp = spawn(command, args, { + // stdio: 'ignore', + shell: true, + windowsHide: true, + }); + + cp.on('error', (e) => { + this.logger.error('Failed to init ffmpeg' + e); + }); + + // cp.stdout.on('data', (data) => { + // this._logBuffer(data, 'ffmpeg stdout: '); + // }); + + // cp.stderr.on('data', (data) => { + // this._logBuffer(data, 'ffmpeg stderr: '); + // }); + + return new Promise((resolve, reject) => { + cp.on('close', (code, signal) => { + const pngFiles = glob.sync(`${this.videoFolderPath}${path.sep}*.png`); + + if ( + pngFiles && + Array.isArray(pngFiles) && + pngFiles.length > 0 + ) { + pngFiles.map(fs.unlinkSync); + } + + resolve(videoPath); + }); + }); + } + _logBuffer (buffer, prefix) { + const lines = buffer.toString().trim().split('\n'); + lines.forEach((line) => { + this.logger.debug(prefix + line); + }); + } +} \ No newline at end of file diff --git a/src/reporter/FileReporterBase.js b/src/reporter/FileReporterBase.js index 17dcd8dc..f6824440 100644 --- a/src/reporter/FileReporterBase.js +++ b/src/reporter/FileReporterBase.js @@ -99,6 +99,29 @@ export default class FileReporterBase extends ReporterBase { step.screenshot = null; // don't save base64 screenshot date to the file } } + // move video from cwd/video to report folder + moveVideoToReports(results, folderPath) { + let tmpVideoFolder; + for (let result of results) { + for (let suite of result.suites) { + for (let caze of suite.cases) { + if (caze && caze.video) { + const fileName = path.basename(caze.video); + tmpVideoFolder = path.dirname(caze.video); + const newVideoPath = path.join(folderPath, fileName); + // mode video to report folder + fs.renameSync(caze.video, newVideoPath); + caze.video = newVideoPath; + } + } + } + } + + // remove tmp video folder + if (tmpVideoFolder) { + fs.rmdirSync(tmpVideoFolder); + } + } // format total duration like : 56.2 sec / 31 min 59 sec / 1 hr 31 min 59 sec formatTotalDuration(results) { if (!Array.isArray(results)) { diff --git a/src/runners/oxygen/index.js b/src/runners/oxygen/index.js index 11b589fc..418f1185 100644 --- a/src/runners/oxygen/index.js +++ b/src/runners/oxygen/index.js @@ -548,6 +548,7 @@ export default class OxygenRunner extends EventEmitter { caseResult.steps = resultStore && resultStore.steps ? resultStore.steps : []; caseResult.logs = resultStore && resultStore.logs ? resultStore.logs : []; caseResult.har = resultStore && resultStore.har ? resultStore.har : null; + caseResult.video = resultStore && resultStore.video ? resultStore.video : null; // determine test case iteration status - mark it as failed if any step has failed var failedSteps = _.find(caseResult.steps, {status: Status.FAILED});