Skip to content

Commit

Permalink
Implement video recording
Browse files Browse the repository at this point in the history
  • Loading branch information
AndreyBelym committed Dec 26, 2018
1 parent 9bdf499 commit 985ce80
Show file tree
Hide file tree
Showing 26 changed files with 553 additions and 40 deletions.
32 changes: 21 additions & 11 deletions src/browser/provider/built-in/chrome/cdp.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,20 @@ async function setEmulation (runtimeInfo) {
await resizeWindow({ width: config.width, height: config.height }, runtimeInfo);
}

async function getScreenshotData (client) {
const { visualViewport } = await client.Page.getLayoutMetrics();

const clipRegion = {
x: visualViewport.pageX,
y: visualViewport.pageY,
width: visualViewport.clientWidth,
height: visualViewport.clientHeight,
scale: visualViewport.scale
};

return await client.Page.captureScreenshot({ fromSurface: true, clip: clipRegion });
}

export async function createClient (runtimeInfo) {
const { browserId, config, cdpPort } = runtimeInfo;

Expand Down Expand Up @@ -99,20 +113,16 @@ export async function updateMobileViewportSize (runtimeInfo) {
runtimeInfo.viewportSize.height = windowDimensions.outerHeight;
}

export async function takeScreenshot (path, { client }) {
const { visualViewport } = await client.Page.getLayoutMetrics();
export async function getVideoFrameData ({ client }) {
const screenshotData = await getScreenshotData(client);

const clipRegion = {
x: visualViewport.pageX,
y: visualViewport.pageY,
width: visualViewport.clientWidth,
height: visualViewport.clientHeight,
scale: visualViewport.scale
};
return Buffer.from(screenshotData.data, 'base64');
}

const screenshot = await client.Page.captureScreenshot({ fromSurface: true, clip: clipRegion });
export async function takeScreenshot (path, { client }) {
const screenshotData = await getScreenshotData(client);

await writeFile(path, screenshot.data, { encoding: 'base64' });
await writeFile(path, screenshotData.data, { encoding: 'base64' });
}

export async function resizeWindow (newDimensions, runtimeInfo) {
Expand Down
5 changes: 5 additions & 0 deletions src/browser/provider/built-in/chrome/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,10 @@ export default {
await this.resizeWindow(browserId, maximumSize.width, maximumSize.height, maximumSize.width, maximumSize.height);
},

async getVideoFrameData (browserId) {
return await cdp.getVideoFrameData(this.openedBrowsers[browserId]);
},

async hasCustomActionForBrowser (browserId) {
const { config, client } = this.openedBrowsers[browserId];

Expand All @@ -97,6 +101,7 @@ export default {
hasMaximizeWindow: !!client && config.headless,
hasTakeScreenshot: !!client,
hasChromelessScreenshots: !!client,
hasGetVideoFrameData: !!client,
hasCanResizeWindowToDimensions: false
};
},
Expand Down
7 changes: 7 additions & 0 deletions src/browser/provider/built-in/firefox/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,13 +84,20 @@ export default {
await this.resizeWindow(browserId, maximumSize.width, maximumSize.height);
},

async getVideoFrameData (browserId) {
const { marionetteClient } = this.openedBrowsers[browserId];

return await marionetteClient.getVideoFrameData();
},

async hasCustomActionForBrowser (browserId) {
const { config, marionetteClient } = this.openedBrowsers[browserId];

return {
hasCloseBrowser: true,
hasTakeScreenshot: !!marionetteClient,
hasChromelessScreenshots: !!marionetteClient,
hasGetVideoFrameData: !!marionetteClient,
hasResizeWindow: !!marionetteClient && config.headless,
hasMaximizeWindow: !!marionetteClient && config.headless,
hasCanResizeWindowToDimensions: false
Expand Down
14 changes: 12 additions & 2 deletions src/browser/provider/built-in/firefox/marionette-client/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,10 @@ module.exports = class MarionetteClient {
return responsePacket.body[3];
}

async _getScreenshotData () {
return await this._getResponse({ command: COMMANDS.takeScreenshot });
}

async connect () {
await this._connectSocket(this.port, this.host);

Expand All @@ -169,9 +173,15 @@ module.exports = class MarionetteClient {
}

async takeScreenshot (path) {
const screenshot = await this._getResponse({ command: COMMANDS.takeScreenshot });
const screenshotData = await this._getScreenshotData();

await writeFile(path, screenshotData.value, { encoding: 'base64' });
}

async getVideoFrameData () {
const screenshotData = await this._getScreenshotData();

await writeFile(path, screenshot.value, { encoding: 'base64' });
return Buffer.from(screenshotData.value, 'base64');
}

async setWindowSize (width, height) {
Expand Down
4 changes: 4 additions & 0 deletions src/browser/provider/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,10 @@ export default class BrowserProvider {
await this.plugin.takeScreenshot(browserId, screenshotPath, pageWidth, pageHeight);
}

async getVideoFrameData (browserId) {
return this.plugin.getVideoFrameData(browserId);
}

async hasCustomActionForBrowser (browserId) {
return this.plugin.hasCustomActionForBrowser(browserId);
}
Expand Down
5 changes: 5 additions & 0 deletions src/browser/provider/plugin-host.js
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ export default class BrowserProviderPluginHost {
hasCloseBrowser: this.hasOwnProperty('closeBrowser'),
hasResizeWindow: this.hasOwnProperty('resizeWindow'),
hasTakeScreenshot: this.hasOwnProperty('takeScreenshot'),
hasGetVideoFrameData: this.hasOwnProperty('getVideoFrameData'),
hasCanResizeWindowToDimensions: this.hasOwnProperty('canResizeWindowToDimensions'),
hasMaximizeWindow: this.hasOwnProperty('maximizeWindow'),
hasChromelessScreenshots: false
Expand All @@ -131,6 +132,10 @@ export default class BrowserProviderPluginHost {
this.reportWarning(browserId, WARNING_MESSAGE.maximizeNotSupportedByBrowserProvider, this[name]);
}

async getVideoFrameData (browserId) {
this.reportWarning(browserId, WARNING_MESSAGE.screenshotNotSupportedByBrowserProvider, this[name]);
}

async reportJobResult (/*browserId, status, data*/) {
return;
}
Expand Down
13 changes: 11 additions & 2 deletions src/cli/argument-parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@ import MESSAGE from '../errors/runtime/message';
import { assertType, is } from '../errors/runtime/type-assertions';
import getViewPortWidth from '../utils/get-viewport-width';
import { wordWrap, splitQuotedText } from '../utils/string';
import parseSslOptions from '../utils/parse-ssl-options';
import { getSSLOptions, getVideoOptions } from '../utils/get-options';
import createFilterFn from '../utils/create-filter-fn';
import { optionValueToRegExp, optionValueToKeyValue } from '../configuration/option-conversion';


const REMOTE_ALIAS_RE = /^remote(?::(\d*))?$/;

const DESCRIPTION = dedent(`
Expand Down Expand Up @@ -76,6 +77,8 @@ export default class CLIArgumentParser {
.option('-F, --fixture-grep <pattern>', 'run only fixtures matching the specified pattern')
.option('-a, --app <command>', 'launch the tested app using the specified command before running tests')
.option('-c, --concurrency <number>', 'run tests concurrently')
.option('--video <pathPattern=value,>', 'capture a video of the running tests')
.option('--video-options <option=value[,...]>', 'video capturing options')
.option('--test-meta <key=value[,key2=value2,...]>', 'run only tests with matching metadata')
.option('--fixture-meta <key=value[,key2=value2,...]>', 'run only fixtures with matching metadata')
.option('--debug-on-fail', 'pause the test if it fails')
Expand Down Expand Up @@ -182,7 +185,7 @@ export default class CLIArgumentParser {

async _parseSslOptions () {
if (this.opts.ssl)
this.opts.ssl = await parseSslOptions(this.opts.ssl);
this.opts.ssl = await getSSLOptions(this.opts.ssl);
}

async _parseReporters () {
Expand All @@ -205,6 +208,11 @@ export default class CLIArgumentParser {
this.src = this.program.args.slice(1);
}

async _parseVideoOptions () {
this.opts.video = typeof this.opts.video === 'string' ? await getVideoOptions(this.opts.video) : {};
this.opts.advancedVideoOptions = typeof this.opts.videoOptions === 'string' ? await getVideoOptions(this.opts.videoOptions) : {};
}

_getProviderName () {
this.opts.providerName = this.opts.listBrowsers === true ? void 0 : this.opts.listBrowsers;
}
Expand Down Expand Up @@ -232,6 +240,7 @@ export default class CLIArgumentParser {
this._parseConcurrency();
this._parseFileList();

await this._parseVideoOptions();
await this._parseSslOptions();
await this._parseReporters();
}
Expand Down
3 changes: 3 additions & 0 deletions src/cli/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,9 @@ async function runTests (argParser) {
.screenshots(opts.screenshots, opts.screenshotsOnFails, opts.screenshotPathPattern)
.startApp(opts.app, opts.appInitDelay);

if (opts.video)
runner.video(opts.video, opts.videoOptions);

runner.once('done-bootstrapping', () => log.hideSpinner());

try {
Expand Down
9 changes: 3 additions & 6 deletions src/configuration/index.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import Promise from 'pinkie';
import { fsObjectExists, readFile } from '../utils/promisified-functions';
import Option from './option';
import optionSource from './option-source';
import { cloneDeep, castArray } from 'lodash';
import { ensureOptionValue as ensureSslOptionValue } from '../utils/parse-ssl-options';
import { getSSLOptions } from '../utils/get-options';
import OPTION_NAMES from './option-names';
import { optionValueToRegExp } from './option-conversion';
import createFilterFn from '../utils/create-filter-fn';
Expand Down Expand Up @@ -100,14 +99,12 @@ export default class Configuration {
}

async _prepareSslOptions () {
const sslOptions = this._options[OPTION_NAMES.ssl];
let sslOptions = this._options[OPTION_NAMES.ssl];

if (!sslOptions)
return;

await Promise.all(Object.entries(sslOptions.value).map(async ([key, value]) => {
sslOptions.value[key] = await ensureSslOptionValue(key, value);
}));
sslOptions = await getSSLOptions(sslOptions);
}

_ensureOption (name, value, source) {
Expand Down
4 changes: 3 additions & 1 deletion src/configuration/option-names.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,7 @@ export default {
proxyBypass: 'proxyBypass',
appCommand: 'appCommand',
appInitDelay: 'appInitDelay',
disableTestSyntaxValidation: 'disableTestSyntaxValidation'
disableTestSyntaxValidation: 'disableTestSyntaxValidation',
video: 'video',
videoOptions: 'videoOptions'
};
2 changes: 2 additions & 0 deletions src/runner/browser-job.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ export default class BrowserJob extends AsyncEventEmitter {
const testRunController = new TestRunController(test, index + 1, this.proxy, this.screenshots, this.warningLog,
this.fixtureHookController, this.opts);

testRunController.on('test-run-create', testRunInfo => this.emit('test-run-create', testRunInfo));
testRunController.on('test-run-ready', () => this.emit('test-run-ready', testRunController.testRun));
testRunController.on('test-run-start', () => this.emit('test-run-start', testRunController.testRun));
testRunController.on('test-run-restart', () => this._onTestRunRestart(testRunController));
testRunController.on('test-run-done', () => this._onTestRunDone(testRunController));
Expand Down
34 changes: 28 additions & 6 deletions src/runner/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { GeneralError } from '../errors/runtime';
import MESSAGE from '../errors/runtime/message';
import { assertType, is } from '../errors/runtime/type-assertions';
import renderForbiddenCharsList from '../errors/render-forbidden-chars-list';
import detectFFMPEG from '../utils/detect-ffmpeg';
import checkFilePath from '../utils/check-file-path';
import { addRunningTest, removeRunningTest, startHandlingTestErrors, stopHandlingTestErrors } from '../utils/handle-errors';
import OPTION_NAMES from '../configuration/option-names';
Expand Down Expand Up @@ -211,8 +212,23 @@ export default class Runner extends EventEmitter {
throw new GeneralError(MESSAGE.cantUseScreenshotPathPatternWithoutBaseScreenshotPathSpecified);
}

_validateRunOptions () {
async _validateVideoOptions () {
const videoConfig = this.configuration.getOption(OPTION_NAMES.video);

if (!videoConfig)
return;

videoConfig.basePath = resolvePath(videoConfig.basePath);

if (videoConfig.ffmpegPath)
videoConfig.ffmpegPath = resolvePath(videoConfig.ffmpegPath);
else
videoConfig.ffmpegPath = await detectFFMPEG();
}

async _validateRunOptions () {
this._validateScreenshotOptions();
await this._validateVideoOptions();
this._validateSpeedOption();
this._validateConcurrencyOption();
this._validateProxyBypassOption();
Expand Down Expand Up @@ -345,6 +361,15 @@ export default class Runner extends EventEmitter {
return this;
}

video (options, advancedOptions) {
this.configuration.mergeOptions({
[OPTION_NAMES.video]: options || {},
[OPTION_NAMES.videoOptions]: advancedOptions
});

return this;
}

startApp (command, initDelay) {
this.configuration.mergeOptions({
[OPTION_NAMES.appCommand]: command,
Expand Down Expand Up @@ -375,11 +400,8 @@ export default class Runner extends EventEmitter {
this._setBootstrapperOptions();

const runTaskPromise = Promise.resolve()
.then(() => {
this._validateRunOptions();

return this.bootstrapper.createRunnableConfiguration();
})
.then(() => this._validateRunOptions())
.then(() => this.bootstrapper.createRunnableConfiguration())
.then(({ reporterPlugins, browserSet, tests, testedApp }) => {
this.emit('done-bootstrapping');

Expand Down
12 changes: 12 additions & 0 deletions src/runner/task.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import { pull as remove } from 'lodash';
import moment from 'moment';
import AsyncEventEmitter from '../utils/async-event-emitter';
import BrowserJob from './browser-job';
import Screenshots from '../screenshots';
import VideoRecorder from '../video-recorder';
import WarningLog from '../notifications/warning-log';
import FixtureHookController from './fixture-hook-controller';

export default class Task extends AsyncEventEmitter {
constructor (tests, browserConnectionGroups, proxy, opts) {
super();

this.timeStamp = moment();
this.running = false;
this.browserConnectionGroups = browserConnectionGroups;
this.tests = tests;
Expand All @@ -18,6 +21,9 @@ export default class Task extends AsyncEventEmitter {

this.fixtureHookController = new FixtureHookController(tests, browserConnectionGroups.length);
this.pendingBrowserJobs = this._createBrowserJobs(proxy, this.opts);

if (this.opts.video)
this.videoRecorders = this._createVideoRecorders(this.pendingBrowserJobs);
}

_assignBrowserJobEventHandlers (job) {
Expand Down Expand Up @@ -60,6 +66,12 @@ export default class Task extends AsyncEventEmitter {
});
}

_createVideoRecorders (browserJobs) {
const videoConfig = { timeStamp: this.timeStamp, ...this.opts.video };

return browserJobs.map(browserJob => new VideoRecorder(browserJob, videoConfig, this.opts.videoOptions));
}

// API
abort () {
this.pendingBrowserJobs.forEach(job => job.abort());
Expand Down
12 changes: 10 additions & 2 deletions src/runner/test-run-controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export default class TestRunController extends AsyncEventEmitter {
return test.isLegacy ? LegacyTestRun : TestRun;
}

_createTestRun (connection) {
async _createTestRun (connection) {
const screenshotCapturer = this.screenshots.createCapturerFor(this.test, this.index, this.quarantine, connection, this.warningLog);
const TestRunCtor = this.TestRunCtor;

Expand All @@ -64,6 +64,13 @@ export default class TestRunController extends AsyncEventEmitter {
if (this.testRun.addQuarantineInfo)
this.testRun.addQuarantineInfo(this.quarantine);

await this.emit('test-run-create', {
testRun: this.testRun,
test: this.test,
index: this.index,
quarantine: this.quarantine,
});

return this.testRun;
}

Expand Down Expand Up @@ -139,7 +146,7 @@ export default class TestRunController extends AsyncEventEmitter {
}

async start (connection) {
const testRun = this._createTestRun(connection);
const testRun = await this._createTestRun(connection);

const hookOk = await this.fixtureHookController.runFixtureBeforeHookIfNecessary(testRun);

Expand All @@ -150,6 +157,7 @@ export default class TestRunController extends AsyncEventEmitter {
}

testRun.once('start', () => this.emit('test-run-start'));
testRun.once('ready', () => this.emit('test-run-ready'));
testRun.once('done', () => this._testRunDone());
testRun.once('disconnected', () => this._testRunDisconnected(connection));

Expand Down
Loading

0 comments on commit 985ce80

Please sign in to comment.