From fd1fd5a1210fac499d7a6ba60d27e53a13b36506 Mon Sep 17 00:00:00 2001 From: Andrey Belym Date: Thu, 20 Dec 2018 18:12:37 +0300 Subject: [PATCH 1/3] Implement video recording --- package.json | 1 + src/browser/provider/built-in/chrome/cdp.js | 32 ++-- src/browser/provider/built-in/chrome/index.js | 5 + .../provider/built-in/firefox/index.js | 7 + .../firefox/marionette-client/index.js | 14 +- src/browser/provider/index.js | 4 + src/browser/provider/plugin-host.js | 5 + src/cli/argument-parser.js | 13 +- src/cli/cli.js | 1 + src/configuration/index.js | 57 ++++-- src/configuration/option-names.js | 5 +- src/errors/runtime/message.js | 42 ++++- src/notifications/warning-message.js | 15 +- src/runner/browser-job.js | 3 + src/runner/index.js | 51 +++++- src/runner/task.js | 12 ++ src/runner/test-run-controller.js | 13 +- src/screenshots/index.js | 7 +- src/test-run/index.js | 21 ++- src/utils/convert-to-best-fit-type.js | 19 ++ src/utils/correct-file-path.js | 1 + src/utils/detect-ffmpeg.js | 45 +++++ src/utils/get-options/base.js | 42 +++++ src/utils/get-options/index.js | 4 + src/utils/get-options/ssl.js | 51 ++++++ src/utils/get-options/video.js | 6 + src/utils/parse-file-list.js | 7 +- src/utils/parse-ssl-options.js | 73 -------- src/{screenshots => utils}/path-pattern.js | 37 ++-- src/utils/promisified-functions.js | 1 - src/video-recorder/index.js | 173 ++++++++++++++++++ src/video-recorder/process.js | 166 +++++++++++++++++ test/functional/assertion-helper.js | 19 +- test/functional/config.js | 5 +- .../native-dialogs-handling/iframe/test.js | 2 +- .../testcafe-fixtures/page-load-test.js | 14 +- .../es-next/native-dialogs-handling/test.js | 2 +- .../testcafe-fixtures/page-load-test.js | 14 +- .../fixtures/video-recording/pages/index.html | 8 + .../fixtures/video-recording/test.js | 103 +++++++++++ .../testcafe-fixtures/index-test.js | 18 ++ test/functional/setup.js | 6 +- test/functional/site/server.js | 9 +- test/server/cli-argument-parser-test.js | 36 +++- test/server/configuration-test.js | 21 ++- test/server/helpers/console-wrapper.js | 33 +++- test/server/path-pattern-test.js | 51 +++--- test/server/runner-test.js | 30 +++ 48 files changed, 1088 insertions(+), 216 deletions(-) create mode 100644 src/utils/convert-to-best-fit-type.js create mode 100644 src/utils/detect-ffmpeg.js create mode 100644 src/utils/get-options/base.js create mode 100644 src/utils/get-options/index.js create mode 100644 src/utils/get-options/ssl.js create mode 100644 src/utils/get-options/video.js delete mode 100644 src/utils/parse-ssl-options.js rename src/{screenshots => utils}/path-pattern.js (71%) create mode 100644 src/video-recorder/index.js create mode 100644 src/video-recorder/process.js create mode 100644 test/functional/fixtures/video-recording/pages/index.html create mode 100644 test/functional/fixtures/video-recording/test.js create mode 100644 test/functional/fixtures/video-recording/testcafe-fixtures/index-test.js diff --git a/package.json b/package.json index b4f8dce8a6c..5569adb69c7 100644 --- a/package.json +++ b/package.json @@ -130,6 +130,7 @@ }, "devDependencies": { "@belym.a.2105/broken-link-checker": "^0.7.9", + "@ffmpeg-installer/ffmpeg": "^1.0.17", "@types/chai": "^3.5.2", "babel-eslint": "^7.1.1", "babel-plugin-add-module-exports": "^0.2.0", diff --git a/src/browser/provider/built-in/chrome/cdp.js b/src/browser/provider/built-in/chrome/cdp.js index ec85bb702fa..bd863b30245 100644 --- a/src/browser/provider/built-in/chrome/cdp.js +++ b/src/browser/provider/built-in/chrome/cdp.js @@ -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; @@ -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 frameData = await getScreenshotData(client); - const clipRegion = { - x: visualViewport.pageX, - y: visualViewport.pageY, - width: visualViewport.clientWidth, - height: visualViewport.clientHeight, - scale: visualViewport.scale - }; + return Buffer.from(frameData.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) { diff --git a/src/browser/provider/built-in/chrome/index.js b/src/browser/provider/built-in/chrome/index.js index cb6c0754427..93d28bac8cb 100644 --- a/src/browser/provider/built-in/chrome/index.js +++ b/src/browser/provider/built-in/chrome/index.js @@ -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]; @@ -97,6 +101,7 @@ export default { hasMaximizeWindow: !!client && config.headless, hasTakeScreenshot: !!client, hasChromelessScreenshots: !!client, + hasGetVideoFrameData: !!client, hasCanResizeWindowToDimensions: false }; }, diff --git a/src/browser/provider/built-in/firefox/index.js b/src/browser/provider/built-in/firefox/index.js index 2cf7c33cde8..e7be2f39239 100644 --- a/src/browser/provider/built-in/firefox/index.js +++ b/src/browser/provider/built-in/firefox/index.js @@ -84,6 +84,12 @@ 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]; @@ -91,6 +97,7 @@ export default { hasCloseBrowser: true, hasTakeScreenshot: !!marionetteClient, hasChromelessScreenshots: !!marionetteClient, + hasGetVideoFrameData: !!marionetteClient, hasResizeWindow: !!marionetteClient && config.headless, hasMaximizeWindow: !!marionetteClient && config.headless, hasCanResizeWindowToDimensions: false diff --git a/src/browser/provider/built-in/firefox/marionette-client/index.js b/src/browser/provider/built-in/firefox/marionette-client/index.js index ef3a9e60d4b..2ad25a415fa 100644 --- a/src/browser/provider/built-in/firefox/marionette-client/index.js +++ b/src/browser/provider/built-in/firefox/marionette-client/index.js @@ -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); @@ -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 frameData = await this._getScreenshotData(); - await writeFile(path, screenshot.value, { encoding: 'base64' }); + return Buffer.from(frameData.value, 'base64'); } async setWindowSize (width, height) { diff --git a/src/browser/provider/index.js b/src/browser/provider/index.js index bd9a4caf215..7112b693187 100644 --- a/src/browser/provider/index.js +++ b/src/browser/provider/index.js @@ -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); } diff --git a/src/browser/provider/plugin-host.js b/src/browser/provider/plugin-host.js index 6608d7d696a..62b1e3327e0 100644 --- a/src/browser/provider/plugin-host.js +++ b/src/browser/provider/plugin-host.js @@ -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 @@ -131,6 +132,10 @@ export default class BrowserProviderPluginHost { this.reportWarning(browserId, WARNING_MESSAGE.maximizeNotSupportedByBrowserProvider, this[name]); } + async getVideoFrameData (browserId) { + this.reportWarning(browserId, WARNING_MESSAGE.videoNotSupportedByBrowserProvider, this[name]); + } + async reportJobResult (/*browserId, status, data*/) { return; } diff --git a/src/cli/argument-parser.js b/src/cli/argument-parser.js index 8f0de424a81..ac9693b0fd9 100644 --- a/src/cli/argument-parser.js +++ b/src/cli/argument-parser.js @@ -6,7 +6,7 @@ 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 getFilterFn from '../utils/get-filter-fn'; const REMOTE_ALIAS_RE = /^remote(?::(\d*))?$/; @@ -89,6 +89,9 @@ export default class CLIArgumentParser { .option('--proxy ', 'specify the host of the proxy server') .option('--proxy-bypass ', 'specify a comma-separated list of rules that define URLs accessed bypassing the proxy server') .option('--ssl ', 'specify SSL options to run TestCafe proxy server over the HTTPS protocol') + .option('--video ', ' record videos of test runs') + .option('--video-options ', 'specify video recording options') + .option('--video-encoding-options ', 'specify encoding options') .option('--disable-page-reloads', 'disable page reloads between tests') .option('--dev', 'enables mechanisms to log and diagnose errors') .option('--qr-code', 'outputs QR-code that repeats URLs used to connect the remote browsers') @@ -177,7 +180,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 () { @@ -200,6 +203,11 @@ export default class CLIArgumentParser { this.src = this.program.args.slice(1); } + async _parseVideoOptions () { + this.opts.videoOptions = typeof this.opts.videoOptions === 'string' ? await getVideoOptions(this.opts.videoOptions) : null; + this.opts.videoEncodingOptions = typeof this.opts.videoEncodingOptions === 'string' ? await getVideoOptions(this.opts.videoEncodingOptions) : null; + } + _getProviderName () { this.opts.providerName = this.opts.listBrowsers === true ? void 0 : this.opts.listBrowsers; } @@ -227,6 +235,7 @@ export default class CLIArgumentParser { this._parseConcurrency(); this._parseFileList(); + await this._parseVideoOptions(); await this._parseSslOptions(); await this._parseReporters(); } diff --git a/src/cli/cli.js b/src/cli/cli.js index 0fc1bf3dd09..e0c2874c67d 100644 --- a/src/cli/cli.js +++ b/src/cli/cli.js @@ -86,6 +86,7 @@ async function runTests (argParser) { .reporter(argParser.opts.reporter) .concurrency(argParser.opts.concurrency) .filter(argParser.filter) + .video(opts.video, opts.videoOptions, opts.videoEncodingOptions) .screenshots(opts.screenshots, opts.screenshotsOnFails, opts.screenshotPathPattern) .startApp(opts.app, opts.appInitDelay); diff --git a/src/configuration/index.js b/src/configuration/index.js index 095eeaa6494..609f7149257 100644 --- a/src/configuration/index.js +++ b/src/configuration/index.js @@ -1,16 +1,17 @@ -import Promise from 'pinkie'; -import { fsObjectExists, readFile } from '../utils/promisified-functions'; +import debug from 'debug'; +import { stat, 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 getFilterFn from '../utils/get-filter-fn'; import resolvePathRelativelyCwd from '../utils/resolve-path-relatively-cwd'; import JSON5 from 'json5'; -import warningMessage from '../notifications/warning-message'; import renderTemplate from '../utils/render-template'; import prepareReporters from '../utils/prepare-reporters'; +import WARNING_MESSAGES from '../notifications/warning-message'; + import { DEFAULT_TIMEOUT, DEFAULT_SPEED_VALUE, @@ -19,6 +20,8 @@ import { DEFAULT_CONCURRENCY_VALUE } from './default-values'; +const DEBUG_LOGGER = debug('testcafe:configuration'); + const CONFIGURATION_FILENAME = '.testcaferc.json'; const OPTION_FLAG_NAMES = [ @@ -52,8 +55,34 @@ export default class Configuration { return result; } + static async _isConfigurationFileExists (path) { + try { + await stat(path); + + return true; + } + catch (error) { + DEBUG_LOGGER(renderTemplate(WARNING_MESSAGES.cantFindConfigurationFile, path, error.stack)); + + return false; + } + } + + static _showConsoleWarning (message) { + process.stdout.write(message + '\n'); + } + + static _showWarningForError (error, warningTemplate, ...args) { + const message = renderTemplate(warningTemplate, ...args); + + Configuration._showConsoleWarning(message); + + DEBUG_LOGGER(message); + DEBUG_LOGGER(error); + } + async _load () { - if (!await fsObjectExists(this.filePath)) + if (!await Configuration._isConfigurationFileExists(this.filePath)) return; let configurationFileContent = null; @@ -61,8 +90,10 @@ export default class Configuration { try { configurationFileContent = await readFile(this.filePath); } - catch (e) { - console.log(warningMessage.errorReadConfigFile); // eslint-disable-line no-console + catch (error) { + Configuration._showWarningForError(error, WARNING_MESSAGES.cantReadConfigFile); + + return; } try { @@ -70,8 +101,10 @@ export default class Configuration { this._options = Configuration._fromObj(optionsObj); } - catch (e) { - console.log(warningMessage.errorConfigFileCannotBeParsed); // eslint-disable-line no-console + catch (error) { + Configuration._showWarningForError(error, WARNING_MESSAGES.cantParseConfigFile); + + return; } await this._normalizeOptionsAfterLoad(); @@ -120,9 +153,7 @@ export default class Configuration { if (!sslOptions) return; - await Promise.all(Object.entries(sslOptions.value).map(async ([key, value]) => { - sslOptions.value[key] = await ensureSslOptionValue(key, value); - })); + sslOptions.value = await getSSLOptions(sslOptions.value); } _ensureOption (name, value, source) { @@ -199,7 +230,7 @@ export default class Configuration { const optionsStr = this._overridenOptions.map(option => `"${option}"`).join(', '); const optionsSuffix = this._overridenOptions.length > 1 ? 's' : ''; - console.log(renderTemplate(warningMessage.configOptionsWereOverriden, optionsStr, optionsSuffix)); // eslint-disable-line no-console + Configuration._showConsoleWarning(renderTemplate(WARNING_MESSAGES.configOptionsWereOverriden, optionsStr, optionsSuffix)); this._overridenOptions = []; } diff --git a/src/configuration/option-names.js b/src/configuration/option-names.js index a701edd4341..e6ef2bb051e 100644 --- a/src/configuration/option-names.js +++ b/src/configuration/option-names.js @@ -22,5 +22,8 @@ export default { stopOnFirstFail: 'stopOnFirstFail', selectorTimeout: 'selectorTimeout', assertionTimeout: 'assertionTimeout', - pageLoadTimeout: 'pageLoadTimeout' + pageLoadTimeout: 'pageLoadTimeout', + videoPath: 'videoPath', + videoOptions: 'videoOptions', + videoEncodingOptions: 'videoEncodingOptions' }; diff --git a/src/errors/runtime/message.js b/src/errors/runtime/message.js index 53f48f6664e..f8bdad9b169 100644 --- a/src/errors/runtime/message.js +++ b/src/errors/runtime/message.js @@ -18,7 +18,6 @@ export default { multipleStdoutReporters: 'Multiple reporters attempting to write to stdout: "{reporters}". Only one reporter can write to stdout.', optionValueIsNotValidRegExp: 'The "{optionName}" option value is not a valid regular expression.', optionValueIsNotValidKeyValue: 'The "{optionName}" option value is not a valid key-value pair.', - testedAppFailedWithError: 'Tested app failed with an error:\n\n{errMessage}', invalidSpeedValue: 'Speed should be a number between 0.01 and 1.', invalidConcurrencyFactor: 'The concurrency factor should be an integer greater or equal to 1.', cannotDivideRemotesCountByConcurrency: 'The number of remote browsers should be divisible by the factor of concurrency.', @@ -26,8 +25,6 @@ export default { portIsNotFree: 'The specified {portNum} port is already in use by another program.', invalidHostname: 'The specified "{hostname}" hostname cannot be resolved to the current machine.', cantFindSpecifiedTestSource: 'Cannot find a test source file at "{path}".', - cannotParseRawFile: 'Cannot parse a test source file in the raw format at "{path}" due to an error.\n\n{errMessage}', - cannotPrepareTestsDueToError: 'Cannot prepare tests due to an error.\n\n{errMessage}', clientFunctionCodeIsNotAFunction: '{#instantiationCallsiteName} code is expected to be specified as a function, but {type} was passed.', selectorInitializedWithWrongType: '{#instantiationCallsiteName} is expected to be initialized with a function, CSS selector string, another Selector, node snapshot or a Promise returned by a Selector, but {type} was passed.', clientFunctionCantResolveTestRun: "{#instantiationCallsiteName} cannot implicitly resolve the test run in context of which it should be executed. If you need to call {#instantiationCallsiteName} from the Node.js API callback, pass the test controller manually via {#instantiationCallsiteName}'s `.with({ boundTestRun: t })` method first. Note that you cannot execute {#instantiationCallsiteName} outside the test code.", @@ -35,12 +32,43 @@ export default { invalidClientFunctionTestRunBinding: 'The "boundTestRun" option value is expected to be a test controller.', invalidValueType: '{smthg} is expected to be a {type}, but it was {actual}.', unsupportedUrlProtocol: 'The specified "{url}" test page URL uses an unsupported {protocol}:// protocol. Only relative URLs or absolute URLs with http://, https:// and file:// protocols are supported.', - unableToOpenBrowser: 'Was unable to open the browser "{alias}" due to error.\n\n{errMessage}', testControllerProxyCantResolveTestRun: `Cannot implicitly resolve the test run in the context of which the test controller action should be executed. Use test function's 't' argument instead.`, - requestHookConfigureAPIError: 'There was an error while configuring the request hook:\n\n{requestHookName}: {errMsg}', timeLimitedPromiseTimeoutExpired: 'Timeout expired for a time limited promise', - forbiddenCharatersInScreenshotPath: 'There are forbidden characters in the "{screenshotPath}" {screenshotPathType}:\n {forbiddenCharsDescription}', cantUseScreenshotPathPatternWithoutBaseScreenshotPathSpecified: 'Cannot use the screenshot path pattern without a base screenshot path specified', + cantSetVideoOptionsWithoutBaseVideoPathSpecified: 'Unable to set video or encoding options when video recording is disabled. Specify the base path where video files are stored to enable recording.', multipleAPIMethodCallForbidden: 'You cannot call the "{methodName}" method more than once. Pass an array of parameters to this method instead.', - invalidReporterOutput: "Specify a file name or a writable stream as the reporter's output target." + invalidReporterOutput: "Specify a file name or a writable stream as the reporter's output target.", + + cantReadSSLCertFile: 'Unable to read the "{path}" file, specified by the "{option}" ssl option. Error details:\n' + + '\n' + + '{err}', + + cannotPrepareTestsDueToError: 'Cannot prepare tests due to an error.\n' + + '\n' + + '{errMessage}', + + cannotParseRawFile: 'Cannot parse a test source file in the raw format at "{path}" due to an error.\n' + + '\n' + + '{errMessage}', + + testedAppFailedWithError: 'Tested app failed with an error:\n' + + '\n' + + '{errMessage}', + + unableToOpenBrowser: 'Was unable to open the browser "{alias}" due to error.\n' + + '\n' + + '{errMessage}', + + requestHookConfigureAPIError: 'There was an error while configuring the request hook:\n' + + '\n' + + '{requestHookName}: {errMsg}', + + forbiddenCharatersInScreenshotPath: 'There are forbidden characters in the "{screenshotPath}" {screenshotPathType}:\n' + + ' {forbiddenCharsDescription}', + + cantFindFFMPEG: 'Unable to locate the FFmpeg executable required to record videos. Do one of the following:\n' + + '\n' + + '* add the FFmpeg installation directory to the PATH environment variable,\n' + + '* specify the path to the FFmpeg executable in the FFMPEG_PATH environment variable or the ffmpegPath video option,\n' + + '* install the @ffmpeg-installer/ffmpeg package from npm.', }; diff --git a/src/notifications/warning-message.js b/src/notifications/warning-message.js index 2db16f23460..61fda5be46b 100644 --- a/src/notifications/warning-message.js +++ b/src/notifications/warning-message.js @@ -5,13 +5,22 @@ export default { screenshotRewritingError: 'The file at "{screenshotPath}" already exists. It has just been rewritten with a recent screenshot. This situation can possibly cause issues. To avoid them, make sure that each screenshot has a unique path. If a test runs in multiple browsers, consider including the user agent in the screenshot path or generate a unique identifier in another way.', browserManipulationsOnRemoteBrowser: 'The screenshot and window resize functionalities are not supported in a remote browser. They can function only if the browser is running on the same machine and in the same environment as the TestCafe server.', screenshotNotSupportedByBrowserProvider: 'The screenshot functionality is not supported by the "{providerName}" browser provider.', + videoNotSupportedByBrowserProvider: 'The video recording functionality is not supported by the "{providerName}" browser provider.', resizeNotSupportedByBrowserProvider: 'The window resize functionality is not supported by the "{providerName}" browser provider.', maximizeNotSupportedByBrowserProvider: 'The window maximization functionality is not supported by the "{providerName}" browser provider.', resizeError: 'Was unable to resize the window due to an error.\n\n{errMessage}', maximizeError: 'Was unable to maximize the window due to an error.\n\n{errMessage}', requestMockCORSValidationFailed: '{RequestHook}: CORS validation failed for a request specified as {requestFilterRule}', debugInHeadlessError: 'You cannot debug in headless mode.', - errorReadConfigFile: 'An error has occurred while reading the configuration file.', - errorConfigFileCannotBeParsed: "Failed to parse the '.testcaferc.json' file.\\n\\nThis file is not a well-formed JSON file.", - configOptionsWereOverriden: 'The {optionsString} option{suffix} from the configuration file will be ignored.' + cantReadConfigFile: 'An error has occurred while reading the configuration file.', + cantParseConfigFile: "Failed to parse the '.testcaferc.json' file.\\n\\nThis file is not a well-formed JSON file.", + configOptionsWereOverriden: 'The {optionsString} option{suffix} from the configuration file will be ignored.', + + cantFindSSLCertFile: 'Unable to find the "{path}" file, specified by the "{option}" ssl option. Error details:\n' + + '\n' + + '{err}', + + cantFindConfigurationFile: 'Unable to find the "{path}" configuration file. Error details:\n' + + '\n' + + '{err}' }; diff --git a/src/runner/browser-job.js b/src/runner/browser-job.js index 7c685e67f35..00333a68f4c 100644 --- a/src/runner/browser-job.js +++ b/src/runner/browser-job.js @@ -36,8 +36,11 @@ 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-start', () => this.emit('test-run-start', testRunController.testRun)); + testRunController.on('test-run-ready', () => this.emit('test-run-ready', testRunController.testRun)); testRunController.on('test-run-restart', () => this._onTestRunRestart(testRunController)); + testRunController.on('test-run-before-done', () => this.emit('test-run-before-done', testRunController.testRun)); testRunController.on('test-run-done', () => this._onTestRunDone(testRunController)); return testRunController; diff --git a/src/runner/index.js b/src/runner/index.js index 2cab1d90950..493fa95466a 100644 --- a/src/runner/index.js +++ b/src/runner/index.js @@ -12,6 +12,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'; @@ -229,8 +230,39 @@ export default class Runner extends EventEmitter { throw new GeneralError(MESSAGE.cantUseScreenshotPathPatternWithoutBaseScreenshotPathSpecified); } - _validateRunOptions () { + async _validateVideoOptions () { + const videoPath = this.configuration.getOption(OPTION_NAMES.videoPath); + const videoEncodingOptions = this.configuration.getOption(OPTION_NAMES.videoEncodingOptions); + + let videoOptions = this.configuration.getOption(OPTION_NAMES.videoOptions); + + if (!videoPath) { + if (videoOptions || videoEncodingOptions) + throw new GeneralError(MESSAGE.cantSetVideoOptionsWithoutBaseVideoPathSpecified); + + return; + } + + this.configuration.mergeOptions({ [OPTION_NAMES.videoPath]: resolvePath(videoPath) }); + + if (!videoOptions) { + videoOptions = {}; + + this.configuration.mergeOptions({ [OPTION_NAMES.videoOptions]: videoOptions }); + } + + if (videoOptions.ffmpegPath) + videoOptions.ffmpegPath = resolvePath(videoOptions.ffmpegPath); + else + videoOptions.ffmpegPath = await detectFFMPEG(); + + if (!videoOptions.ffmpegPath) + throw new GeneralError(MESSAGE.cantFindFFMPEG); + } + + async _validateRunOptions () { this._validateScreenshotOptions(); + await this._validateVideoOptions(); this._validateSpeedOption(); this._validateConcurrencyOption(); this._validateProxyBypassOption(); @@ -335,6 +367,16 @@ export default class Runner extends EventEmitter { return this; } + video (path, options, encodingOptions) { + this.configuration.mergeOptions({ + [OPTION_NAMES.videoPath]: path, + [OPTION_NAMES.videoOptions]: options, + [OPTION_NAMES.videoEncodingOptions]: encodingOptions + }); + + return this; + } + startApp (command, initDelay) { this.configuration.mergeOptions({ [OPTION_NAMES.appCommand]: command, @@ -383,11 +425,8 @@ export default class Runner extends EventEmitter { this._setBootstrapperOptions(); const runTaskPromise = Promise.resolve() - .then(() => { - this._validateRunOptions(); - - return this._createRunnableConfiguration(); - }) + .then(() => this._validateRunOptions()) + .then(() => this._createRunnableConfiguration()) .then(({ reporterPlugins, browserSet, tests, testedApp }) => { this.emit('done-bootstrapping'); diff --git a/src/runner/task.js b/src/runner/task.js index 7f166f4e6f2..e51ed70cd3e 100644 --- a/src/runner/task.js +++ b/src/runner/task.js @@ -1,7 +1,9 @@ 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'; @@ -9,6 +11,7 @@ export default class Task extends AsyncEventEmitter { constructor (tests, browserConnectionGroups, proxy, opts) { super(); + this.timeStamp = moment(); this.running = false; this.browserConnectionGroups = browserConnectionGroups; this.tests = tests; @@ -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.videoPath) + this.videoRecorders = this._createVideoRecorders(this.pendingBrowserJobs); } _assignBrowserJobEventHandlers (job) { @@ -60,6 +66,12 @@ export default class Task extends AsyncEventEmitter { }); } + _createVideoRecorders (browserJobs) { + const videoOptions = { timeStamp: this.timeStamp, ...this.opts.videoOptions }; + + return browserJobs.map(browserJob => new VideoRecorder(browserJob, this.opts.videoPath, videoOptions, this.opts.videoEncodingOptions)); + } + // API abort () { this.pendingBrowserJobs.forEach(job => job.abort()); diff --git a/src/runner/test-run-controller.js b/src/runner/test-run-controller.js index d53fe98f203..faebe8d68bf 100644 --- a/src/runner/test-run-controller.js +++ b/src/runner/test-run-controller.js @@ -64,7 +64,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; @@ -73,6 +73,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; } @@ -144,7 +151,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); @@ -155,6 +162,8 @@ export default class TestRunController extends AsyncEventEmitter { } testRun.once('start', () => this.emit('test-run-start')); + testRun.once('ready', () => this.emit('test-run-ready')); + testRun.once('before-done', () => this.emit('test-run-before-done')); testRun.once('done', () => this._testRunDone()); testRun.once('disconnected', () => this._testRunDisconnected(connection)); diff --git a/src/screenshots/index.js b/src/screenshots/index.js index c59bf18c7e2..cda15783f71 100644 --- a/src/screenshots/index.js +++ b/src/screenshots/index.js @@ -1,9 +1,12 @@ import { find } from 'lodash'; import moment from 'moment'; import Capturer from './capturer'; -import PathPattern from './path-pattern'; +import PathPattern from '../utils/path-pattern'; import getCommonPath from '../utils/get-common-path'; + +const SCREENSHOT_EXTENSION = 'png'; + export default class Screenshots { constructor (path, pattern) { this.enabled = !!path; @@ -54,7 +57,7 @@ export default class Screenshots { createCapturerFor (test, testIndex, quarantine, connection, warningLog) { const testEntry = this._ensureTestEntry(test); - const pathPattern = new PathPattern(this.screenshotsPattern, { + const pathPattern = new PathPattern(this.screenshotsPattern, SCREENSHOT_EXTENSION, { testIndex, quarantineAttempt: quarantine ? quarantine.getNextAttemptNumber() : null, now: this.now, diff --git a/src/test-run/index.js b/src/test-run/index.js index 8d0610d81f5..224c408e71e 100644 --- a/src/test-run/index.js +++ b/src/test-run/index.js @@ -1,9 +1,9 @@ -import EventEmitter from 'events'; import { pull as remove } from 'lodash'; import { readSync as read } from 'read-file-relative'; import promisifyEvent from 'promisify-event'; import Promise from 'pinkie'; import Mustache from 'mustache'; +import AsyncEventEmitter from '../utils/async-event-emitter'; import debugLogger from '../notifications/debug-logger'; import TestRunDebugLog from './debug-log'; import TestRunErrorFormattableAdapter from '../errors/test-run/formattable-adapter'; @@ -50,7 +50,7 @@ const MAX_RESPONSE_DELAY = 3000; const ALL_DRIVER_TASKS_ADDED_TO_QUEUE_EVENT = 'all-driver-tasks-added-to-queue'; -export default class TestRun extends EventEmitter { +export default class TestRun extends AsyncEventEmitter { constructor (test, browserConnection, screenshotCapturer, globalWarningLog, opts) { super(); @@ -275,12 +275,16 @@ export default class TestRun extends EventEmitter { async start () { testRunTracker.activeTestRuns[this.session.id] = this; - this.emit('start'); + await this.emit('start'); const onDisconnected = err => this._disconnect(err); this.browserConnection.once('disconnected', onDisconnected); + await this.once('connected'); + + await this.emit('ready'); + if (await this._runBeforeHook()) { await this._executeTestFn(PHASE.inTest, this.test.fn); await this._runAfterHook(); @@ -294,13 +298,15 @@ export default class TestRun extends EventEmitter { if (this.errs.length && this.debugOnFail) await this._enqueueSetBreakpointCommand(null, this.debugReporterPluginHost.formatError(this.errs[0])); + await this.emit('before-done'); + await this.executeCommand(new serviceCommands.TestDoneCommand()); this._addPendingPageErrorIfAny(); delete testRunTracker.activeTestRuns[this.session.id]; - this.emit('done'); + await this.emit('done'); } _evaluate (code) { @@ -342,12 +348,12 @@ export default class TestRun extends EventEmitter { if (this.pendingRequest) this._resolvePendingRequest(command); - return new Promise((resolve, reject) => { + return new Promise(async (resolve, reject) => { this.addingDriverTasksCount--; this.driverTaskQueue.push({ command, resolve, reject, callsite }); if (!this.addingDriverTasksCount) - this.emit(ALL_DRIVER_TASKS_ADDED_TO_QUEUE_EVENT, this.driverTaskQueue.length); + await this.emit(ALL_DRIVER_TASKS_ADDED_TO_QUEUE_EVENT, this.driverTaskQueue.length); }); } @@ -694,9 +700,12 @@ export default class TestRun extends EventEmitter { // Service message handlers const ServiceMessages = TestRun.prototype; +// NOTE: this function is time-critical and must return ASAP to avoid client disconnection ServiceMessages[CLIENT_MESSAGES.ready] = function (msg) { this.debugLog.driverMessage(msg); + this.emit('connected'); + this._clearPendingRequest(); // NOTE: the driver sends the status for the second time if it didn't get a response at the diff --git a/src/utils/convert-to-best-fit-type.js b/src/utils/convert-to-best-fit-type.js new file mode 100644 index 00000000000..6281f65feb1 --- /dev/null +++ b/src/utils/convert-to-best-fit-type.js @@ -0,0 +1,19 @@ +const NUMBER_REG_EX = /^[0-9-.,]+$/; +const BOOLEAN_STRING_VALUES = ['true', 'false']; + + +export default function (valueStr) { + if (typeof valueStr !== 'string') + return valueStr; + + else if (NUMBER_REG_EX.test(valueStr)) + return parseFloat(valueStr); + + else if (BOOLEAN_STRING_VALUES.includes(valueStr)) + return valueStr === 'true'; + + else if (!valueStr.length) + return void 0; + + return valueStr; +} diff --git a/src/utils/correct-file-path.js b/src/utils/correct-file-path.js index a6a746c8304..064e74c7fe3 100644 --- a/src/utils/correct-file-path.js +++ b/src/utils/correct-file-path.js @@ -7,6 +7,7 @@ export default function (filePath, expectedExtention) { const correctedPath = filePath .split(path.posix.sep) + .filter((fragment, index) => index === 0 || !!fragment) .map(str => sanitizeFilename(str)) .join(path.sep); diff --git a/src/utils/detect-ffmpeg.js b/src/utils/detect-ffmpeg.js new file mode 100644 index 00000000000..e2764ed8ded --- /dev/null +++ b/src/utils/detect-ffmpeg.js @@ -0,0 +1,45 @@ +import { isWin } from 'os-family'; +import resolveCwd from 'resolve-cwd'; +import { exec } from './promisified-functions'; + +const FFMPEG_MODULE_NAME = '@ffmpeg-installer/ffmpeg'; +const FFMPEG_SEARCH_COMMAND = isWin ? 'where' : 'which'; +const FFMPEG_BINARY_NAME = 'ffmpeg'; + +async function findFFMPEGinPath () { + try { + const ffmpegPath = await exec(`${FFMPEG_SEARCH_COMMAND} ${FFMPEG_BINARY_NAME}`); + + return ffmpegPath.trim(); + } + catch (e) { + return ''; + } +} + +async function requireFFMPEGModuleFromCwd () { + try { + const ffmpegModulePath = resolveCwd(FFMPEG_MODULE_NAME); + + return require(ffmpegModulePath).path; + } + catch (e) { + return ''; + } +} + +async function requireFFMPEGModule () { + try { + return require(FFMPEG_MODULE_NAME).path; + } + catch (e) { + return ''; + } +} + +export default async function () { + return process.env.FFMPEG_PATH || + await requireFFMPEGModuleFromCwd() || + await requireFFMPEGModule() || + await findFFMPEGinPath(); +} diff --git a/src/utils/get-options/base.js b/src/utils/get-options/base.js new file mode 100644 index 00000000000..1f0682d5cf0 --- /dev/null +++ b/src/utils/get-options/base.js @@ -0,0 +1,42 @@ +import Promise from 'pinkie'; +import convertToBestFitType from '../convert-to-best-fit-type'; + + +const DEFAULT_OPTIONS_SEPARATOR = ','; +const DEFAULT_KEY_VALUE_SEPARATOR = '='; + +const DEFAULT_ON_OPTION_PARSED = (key, value) => value; + +function parseOptionsString (optionsStr, optionsSeparator, keyValueSeparator) { + return optionsStr + .split(optionsSeparator) + .map(keyValueString => keyValueString.split(keyValueSeparator)) + .map(([key, ...value]) => [key, value.length > 1 ? value.join(keyValueSeparator) : value[0]]); +} + +export default async function (sourceOptions = '', optionsConfig) { + const { + optionsSeparator = DEFAULT_OPTIONS_SEPARATOR, + keyValueSeparator = DEFAULT_KEY_VALUE_SEPARATOR, + onOptionParsed = DEFAULT_ON_OPTION_PARSED + } = optionsConfig; + + const optionsList = typeof sourceOptions === 'string' ? + parseOptionsString(sourceOptions, optionsSeparator, keyValueSeparator) : + Object.entries(sourceOptions); + + const resultOptions = {}; + + await Promise.all(optionsList.map(async ([key, value]) => { + // NOTE: threat a key without a separator and a value as a boolean flag + if (value === void 0) + value = true; + + value = convertToBestFitType(value); + + resultOptions[key] = await onOptionParsed(key, value); + })); + + return resultOptions; +} + diff --git a/src/utils/get-options/index.js b/src/utils/get-options/index.js new file mode 100644 index 00000000000..8397def68a8 --- /dev/null +++ b/src/utils/get-options/index.js @@ -0,0 +1,4 @@ +import getSSLOptions from './ssl'; +import getVideoOptions from './video'; + +export { getVideoOptions, getSSLOptions }; diff --git a/src/utils/get-options/ssl.js b/src/utils/get-options/ssl.js new file mode 100644 index 00000000000..995c08047a1 --- /dev/null +++ b/src/utils/get-options/ssl.js @@ -0,0 +1,51 @@ +import os from 'os'; +import debug from 'debug'; +import baseGetOptions from './base'; +import { GeneralError } from '../../errors/runtime'; +import { stat, readFile } from '../promisified-functions'; +import renderTemplate from '../../utils/render-template'; +import ERROR_MESSAGES from '../../errors/runtime/message'; +import WARNING_MESSAGES from '../../notifications/warning-message'; + + +const DEBUG_LOGGER = debug('testcafe:utils:get-options:ssl'); + +const MAX_PATH_LENGTH = { + 'Linux': 4096, + 'Windows_NT': 260, + 'Darwin': 1024 +}; + +const OS_MAX_PATH_LENGTH = MAX_PATH_LENGTH[os.type()]; + +const OPTIONS_SEPARATOR = ';'; +const FILE_OPTION_NAMES = ['cert', 'key', 'pfx']; + + +export default function (optionString) { + return baseGetOptions(optionString, { + optionsSeparator: OPTIONS_SEPARATOR, + + async onOptionParsed (key, value) { + if (!FILE_OPTION_NAMES.includes(key) || value.length > OS_MAX_PATH_LENGTH) + return value; + + try { + await stat(value); + } + catch (error) { + DEBUG_LOGGER(renderTemplate(WARNING_MESSAGES.cantFindSSLCertFile, value, key, error.stack)); + + return value; + } + + try { + return await readFile(value); + } + catch (error) { + throw new GeneralError(ERROR_MESSAGES.cantReadSSLCertFile, value, key, error.stack); + } + } + }); +} + diff --git a/src/utils/get-options/video.js b/src/utils/get-options/video.js new file mode 100644 index 00000000000..48615906fc0 --- /dev/null +++ b/src/utils/get-options/video.js @@ -0,0 +1,6 @@ +import baseGetOptions from './base'; + + +export default function (options) { + return baseGetOptions(options, {}); +} diff --git a/src/utils/parse-file-list.js b/src/utils/parse-file-list.js index 6ad909474f3..e283148fb05 100644 --- a/src/utils/parse-file-list.js +++ b/src/utils/parse-file-list.js @@ -24,10 +24,9 @@ function modifyFileRoot (baseDir, file) { async function getDefaultDirs (baseDir) { return await globby(DEFAULT_TEST_LOOKUP_DIRS, { - cwd: baseDir, - nocase: true, - onlyDirectories: true, - onlyFiles: false + cwd: baseDir, + nocase: true, + silent: true }); } diff --git a/src/utils/parse-ssl-options.js b/src/utils/parse-ssl-options.js deleted file mode 100644 index 2ba021785f5..00000000000 --- a/src/utils/parse-ssl-options.js +++ /dev/null @@ -1,73 +0,0 @@ -import os from 'os'; -import Promise from 'pinkie'; -import { fsObjectExists, readFile } from './promisified-functions'; - -const MAX_PATH_LENGTH = { - 'Linux': 4096, - 'Windows_NT': 260, - 'Darwin': 1024 -}; - -const OS_MAX_PATH_LENGTH = MAX_PATH_LENGTH[os.type()]; - -const OPTIONS_SEPARATOR = ';'; -const OPTION_KEY_VALUE_SEPARATOR = '='; -const FILE_OPTION_NAMES = ['cert', 'key', 'pfx']; -const NUMBER_REG_EX = /^[0-9-.,]+$/; -const BOOLEAN_STRING_VALUES = ['true', 'false']; - -export default async function (optionsStr = '') { - const splittedOptions = optionsStr.split(OPTIONS_SEPARATOR); - - if (!splittedOptions.length) - return null; - - const parsedOptions = {}; - - await Promise.all(splittedOptions.map(async item => { - const keyValuePair = item.split(OPTION_KEY_VALUE_SEPARATOR); - const key = keyValuePair[0]; - let value = keyValuePair[1]; - - if (!key || !value) - return; - - value = await ensureOptionValue(key, value); - - parsedOptions[key] = value; - })); - - return parsedOptions; -} - -export async function ensureOptionValue (optionName, optionValue) { - optionValue = convertToBestFitType(optionValue); - - return await ensureFileOptionValue(optionName, optionValue); -} - -async function ensureFileOptionValue (optionName, optionValue) { - const isFileOption = FILE_OPTION_NAMES.includes(optionName) && optionValue.length < OS_MAX_PATH_LENGTH; - - if (isFileOption && await fsObjectExists(optionValue)) - optionValue = await readFile(optionValue); - - return optionValue; -} - -function convertToBestFitType (valueStr) { - if (typeof valueStr !== 'string') - return void 0; - - else if (NUMBER_REG_EX.test(valueStr)) - return parseFloat(valueStr); - - else if (BOOLEAN_STRING_VALUES.includes(valueStr)) - return valueStr === 'true'; - - else if (!valueStr.length) - return void 0; - - return valueStr; -} - diff --git a/src/screenshots/path-pattern.js b/src/utils/path-pattern.js similarity index 71% rename from src/screenshots/path-pattern.js rename to src/utils/path-pattern.js index 51c1f6e12ac..89ae07e4c71 100644 --- a/src/screenshots/path-pattern.js +++ b/src/utils/path-pattern.js @@ -5,8 +5,6 @@ import escapeUserAgent from '../utils/escape-user-agent'; const DATE_FORMAT = 'YYYY-MM-DD'; const TIME_FORMAT = 'HH-mm-ss'; -const SCRENSHOT_EXTENTION = 'png'; - const ERRORS_FOLDER = 'errors'; const PLACEHOLDERS = { @@ -21,33 +19,40 @@ const PLACEHOLDERS = { BROWSER: '${BROWSER}', BROWSER_VERSION: '${BROWSER_VERSION}', OS: '${OS}', - OS_VERSION: '${OS_VERSION}' + OS_VERSION: '${OS_VERSION}', + GENERIC_TEST_NAME: '${GENERIC_TEST_NAME}', + GENERIC_RUN_NAME: '${GENERIC_RUN_NAME}' }; -const DEFAULT_PATH_PATTERN_FOR_REPORT = `${PLACEHOLDERS.DATE}_${PLACEHOLDERS.TIME}\\test-${PLACEHOLDERS.TEST_INDEX}`; -const DEFAULT_PATH_PATTERN = `${DEFAULT_PATH_PATTERN_FOR_REPORT}\\${PLACEHOLDERS.USERAGENT}\\${PLACEHOLDERS.FILE_INDEX}.${SCRENSHOT_EXTENTION}`; -const QUARANTINE_MODE_DEFAULT_PATH_PATTERN = `${DEFAULT_PATH_PATTERN_FOR_REPORT}\\run-${PLACEHOLDERS.QUARANTINE_ATTEMPT}\\${PLACEHOLDERS.USERAGENT}\\${PLACEHOLDERS.FILE_INDEX}.${SCRENSHOT_EXTENTION}`; +const DEFAULT_PATH_PATTERN_FOR_REPORT = `${PLACEHOLDERS.DATE}_${PLACEHOLDERS.TIME}\\${PLACEHOLDERS.GENERIC_TEST_NAME}\\` + + `${PLACEHOLDERS.GENERIC_RUN_NAME}\\${PLACEHOLDERS.USERAGENT}\\${PLACEHOLDERS.FILE_INDEX}`; + +const GENERIC_TEST_NAME_TEMPLATE = data => data.testIndex ? `test-${data.testIndex}` : ''; +const GENERIC_RUN_NAME_TEMPLATE = data => data.quarantineAttempt ? `run-${data.quarantineAttempt}` : ''; export default class PathPattern { - constructor (pattern, data) { - this.pattern = this._ensurePattern(pattern, data.quarantineAttempt); + constructor (pattern, fileExtension, data) { + this.pattern = this._ensurePattern(pattern); this.data = this._addDefaultFields(data); this.placeholderToDataMap = this._createPlaceholderToDataMap(); + this.fileExtension = fileExtension; } - _ensurePattern (pattern, quarantineAttempt) { + _ensurePattern (pattern) { if (pattern) return pattern; - return quarantineAttempt ? QUARANTINE_MODE_DEFAULT_PATH_PATTERN : DEFAULT_PATH_PATTERN; + return DEFAULT_PATH_PATTERN_FOR_REPORT; } _addDefaultFields (data) { const defaultFields = { - formattedDate: data.now.format(DATE_FORMAT), - formattedTime: data.now.format(TIME_FORMAT), - fileIndex: 1, - errorFileIndex: 1 + genericTestName: GENERIC_TEST_NAME_TEMPLATE(data), + genericRunName: GENERIC_RUN_NAME_TEMPLATE(data), + formattedDate: data.now.format(DATE_FORMAT), + formattedTime: data.now.format(TIME_FORMAT), + fileIndex: 1, + errorFileIndex: 1 }; return Object.assign({}, defaultFields, data); @@ -55,6 +60,8 @@ export default class PathPattern { _createPlaceholderToDataMap () { return { + [PLACEHOLDERS.GENERIC_TEST_NAME]: this.data.genericTestName, + [PLACEHOLDERS.GENERIC_RUN_NAME]: this.data.genericRunName, [PLACEHOLDERS.DATE]: this.data.formattedDate, [PLACEHOLDERS.TIME]: this.data.formattedTime, [PLACEHOLDERS.TEST_INDEX]: this.data.testIndex, @@ -103,7 +110,7 @@ export default class PathPattern { getPath (forError) { const path = PathPattern._buildPath(this.pattern, this.placeholderToDataMap, forError); - return correctFilePath(path, SCRENSHOT_EXTENTION); + return correctFilePath(path, this.fileExtension); } // For testing purposes diff --git a/src/utils/promisified-functions.js b/src/utils/promisified-functions.js index 1ffa2824cbf..d7b5ec88005 100644 --- a/src/utils/promisified-functions.js +++ b/src/utils/promisified-functions.js @@ -7,7 +7,6 @@ export const stat = promisify(fs.stat); export const writeFile = promisify(fs.writeFile); export const readFile = promisify(fs.readFile); export const deleteFile = promisify(fs.unlink); -export const fsObjectExists = fsPath => stat(fsPath).then(() => true, () => false); export const exec = promisify(childProcess.exec); diff --git a/src/video-recorder/index.js b/src/video-recorder/index.js new file mode 100644 index 00000000000..0cf39f378fe --- /dev/null +++ b/src/video-recorder/index.js @@ -0,0 +1,173 @@ +import debug from 'debug'; +import { join, dirname } from 'path'; +import fs from 'fs'; +import { spawnSync } from 'child_process'; +import makeDir from 'make-dir'; +import VideoRecorderProcess from './process'; +import TempDirectory from '../utils/temp-directory'; +import PathPattern from '../utils/path-pattern'; +import WARNING_MESSAGES from '../notifications/warning-message'; + + +const DEBUG_LOGGER = debug('testcafe:video-recorder'); + +const VIDEO_EXTENSION = 'mp4'; + +const TEMP_DIR_PREFIX = 'video'; +const TEMP_VIDEO_FILE_PREFIX = 'tmp-video'; +const TEMP_MERGE_FILE_PREFIX = TEMP_VIDEO_FILE_PREFIX + '-merge'; + +const TEMP_MERGE_CONFIG_FILE_PREFIX = 'config'; +const TEMP_MERGE_CONFIG_FILE_EXTENSION = 'txt'; + +export default class VideoRecorder { + constructor (browserJob, basePath, opts, encodingOpts) { + this.browserJob = browserJob; + this.basePath = basePath; + this.failedOnly = opts.failedOnly; + this.singleFile = opts.singleFile; + this.ffmpegPath = opts.ffmpegPath; + this.customPathPattern = opts.pathPattern; + this.timeStamp = opts.timeStamp; + this.encodingOptions = encodingOpts; + + this.tempDirectory = new TempDirectory(TEMP_DIR_PREFIX); + this.tempVideoPath = ''; + this.tempMergeConfigPath = ''; + + this.firstFile = true; + + this.testRunInfo = {}; + + this._assignEventHandlers(browserJob); + } + + _createSafeListener (listener) { + return async (...args) => { + try { + return await listener.apply(this, args); + } + catch (error) { + DEBUG_LOGGER(listener && listener.name, error); + + return void 0; + } + }; + } + + _assignEventHandlers (browserJob) { + browserJob.once('start', this._createSafeListener(this._onBrowserJobStart)); + browserJob.once('done', this._createSafeListener(this._onBrowserJobDone)); + browserJob.on('test-run-create', this._createSafeListener(this._onTestRunCreate)); + browserJob.on('test-run-ready', this._createSafeListener(this._onTestRunReady)); + browserJob.on('test-run-before-done', this._createSafeListener(this._onTestRunBeforeDone)); + } + + _getTargetVideoPath (testRunInfo) { + const { test, index, testRun } = testRunInfo; + + const connection = testRun.browserConnection; + + const pathPattern = new PathPattern(this.customPathPattern, VIDEO_EXTENSION, { + testIndex: this.singleFile ? null : index, + quarantineAttempt: null, + now: this.timeStamp, + fixture: this.singleFile ? '' : test.fixture.name, + test: this.singleFile ? '' : test.name, + parsedUserAgent: connection.browserInfo.parsedUserAgent, + }); + + return join(this.basePath, pathPattern.getPath()); + } + + _generateTempNames (id) { + const tempFileNames = { + tempVideoPath: `${TEMP_VIDEO_FILE_PREFIX}-${id}.${VIDEO_EXTENSION}`, + tempMergeConfigPath: `${TEMP_MERGE_CONFIG_FILE_PREFIX}-${id}.${TEMP_MERGE_CONFIG_FILE_EXTENSION}`, + tmpMergeName: `${TEMP_MERGE_FILE_PREFIX}-${id}.${VIDEO_EXTENSION}` + }; + + for (const [tempFile, tempName] of Object.entries(tempFileNames)) + tempFileNames[tempFile] = join(this.tempDirectory.path, tempName); + + return tempFileNames; + } + + _concatVideo (targetVideoPath, { tempVideoPath, tempMergeConfigPath, tmpMergeName }) { + if (this.firstFile) { + this.firstFile = false; + return; + } + + fs.writeFileSync(tempMergeConfigPath, ` + file '${targetVideoPath}' + file '${tempVideoPath}' + `); + + spawnSync(this.ffmpegPath, ['-y', '-f', 'concat', '-safe', '0', '-i', tempMergeConfigPath, '-c', 'copy', tmpMergeName], { stdio: 'ignore' }); + fs.copyFileSync(tmpMergeName, tempVideoPath); + } + + async _onBrowserJobStart () { + await this.tempDirectory.init(); + } + + async _onBrowserJobDone () { + await this.tempDirectory.dispose(); + } + + async _onTestRunCreate ({ testRun, quarantine, test, index }) { + const testRunInfo = { testRun, quarantine, test, index }; + + const connection = testRun.browserConnection; + + const connectionCapabilities = await testRun.browserConnection.provider.hasCustomActionForBrowser(connection.id); + + if (!connectionCapabilities || !connectionCapabilities.hasGetVideoFrameData) { + this.browserJob.warningLog.addWarning(WARNING_MESSAGES.videoNotSupportedByBrowserProvider, connection.browserInfo.providerName); + + return; + } + + this.testRunInfo[testRun.id] = testRunInfo; + + testRunInfo.tempFiles = this._generateTempNames(connection.id); + + + testRunInfo.videoRecorder = new VideoRecorderProcess(testRunInfo.tempFiles.tempVideoPath, this.ffmpegPath, connection, this.encodingOptions); + + await testRunInfo.videoRecorder.init(); + } + + async _onTestRunReady (testRun) { + const testRunInfo = this.testRunInfo[testRun.id]; + + if (!testRunInfo) + return; + + await testRunInfo.videoRecorder.startCapturing(); + } + + async _onTestRunBeforeDone (testRun) { + const testRunInfo = this.testRunInfo[testRun.id]; + + if (!testRunInfo) + return; + + delete this.testRunInfo[testRun.id]; + + await testRunInfo.videoRecorder.finishCapturing(); + + const videoPath = this._getTargetVideoPath(testRunInfo); + + if (this.failedOnly && !testRun.errs.length) + return; + + await makeDir(dirname(videoPath)); + + if (this.singleFile) + this._concatVideo(videoPath, testRunInfo.tempFiles); + + fs.copyFileSync(testRunInfo.tempFiles.tempVideoPath, videoPath); + } +} diff --git a/src/video-recorder/process.js b/src/video-recorder/process.js new file mode 100644 index 00000000000..e37bbd9c46a --- /dev/null +++ b/src/video-recorder/process.js @@ -0,0 +1,166 @@ +import debug from 'debug'; +import { spawn } from 'child_process'; +import { flatten } from 'lodash'; +import Promise from 'pinkie'; +import AsyncEmitter from '../utils/async-event-emitter'; +import delay from '../utils/delay'; + + +const DEBUG_LOGGER_PREFIX = 'testcafe:video-recorder:process:'; + +const DEFAULT_OPTIONS = { + // NOTE: don't ask confirmation for rewriting the output file + 'y': true, + + // NOTE: use the time when a frame is read from the source as its timestamp + // IMPORTANT: must be specified before configuring the source + 'use_wallclock_as_timestamps': 1, + + // NOTE: use stdin as a source + 'i': 'pipe:0', + + // NOTE: use the H.264 video codec + 'c:v': 'libx264', + + // NOTE: use the 'ultrafast' compression preset + 'preset': 'ultrafast', + + // NOTE: use the yuv420p pixel format (the most widely supported) + 'pix_fmt': 'yuv420p', + + // NOTE: scale input frames to make the frame height divisible by 2 (yuv420p's requirement) + 'vf': 'scale=trunc(iw/2)*2:trunc(ih/2)*2', + + // NOTE: set the frame rate to 30 in the output video (the most widely supported) + 'r': 30 +}; + +const FFMPEG_START_DELAY = 500; + +export default class VideoRecorder extends AsyncEmitter { + constructor (basePath, ffmpegPath, connection, customOptions) { + super(); + + this.debugLogger = debug(DEBUG_LOGGER_PREFIX + connection.id); + + this.customOptions = customOptions; + this.videoPath = basePath; + this.connection = connection; + this.ffmpegPath = ffmpegPath; + this.ffmpegProcess = null; + + this.ffmpegStdoutBuf = ''; + this.ffmpegStderrBuf = ''; + + this.ffmpegClosingPromise = null; + + this.closed = false; + + this.optionsList = this._getOptionsList(); + + this.capturingPromise = null; + } + + static _filterOption ([key, value]) { + if (value === true) + return ['-' + key]; + + return ['-' + key, value]; + } + + _setupFFMPEGBuffers () { + this.ffmpegProcess.stdout.on('data', data => { + this.ffmpegStdoutBuf += String(data); + }); + + this.ffmpegProcess.stderr.on('data', data => { + this.ffmpegStderrBuf += String(data); + }); + } + + _getChildProcessPromise () { + return new Promise((resolve, reject) => { + this.ffmpegProcess.on('exit', resolve); + this.ffmpegProcess.on('error', reject); + }); + } + + _getOptionsList () { + const optionsObject = Object.assign({}, DEFAULT_OPTIONS, this.customOptions); + + const optionsList = flatten(Object.entries(optionsObject).map(VideoRecorder._filterOption)); + + optionsList.push(this.videoPath); + + return optionsList; + } + + async _addFrame (frameData) { + const writingFinished = this.ffmpegProcess.stdin.write(frameData); + + if (!writingFinished) + await new Promise(r => this.ffmpegProcess.stdin.once('drain', r)); + } + + async _capture () { + while (!this.closed) { + try { + const frame = await this.connection.provider.getVideoFrameData(this.connection.id); + + if (frame) { + await this.emit('frame'); + await this._addFrame(frame); + } + } + catch (error) { + this.debugLogger(error); + } + } + } + + async init () { + this.ffmpegProcess = spawn(this.ffmpegPath, this.optionsList, { stdio: 'pipe' }); + + this._setupFFMPEGBuffers(); + + this.ffmpegClosingPromise = this + ._getChildProcessPromise() + .then(code => { + this.closed = true; + + if (code) { + this.debugLogger(code); + this.debugLogger(this.ffmpegStdoutBuf); + this.debugLogger(this.ffmpegStderrBuf); + } + }) + .catch(error => { + this.closed = true; + + this.debugLogger(error); + this.debugLogger(this.ffmpegStdoutBuf); + this.debugLogger(this.ffmpegStderrBuf); + }); + + await delay(FFMPEG_START_DELAY); + } + + async startCapturing () { + this.capturingPromise = this._capture(); + + await this.once('frame'); + } + + async finishCapturing () { + if (this.closed) + return; + + this.closed = true; + + await this.capturingPromise; + + this.ffmpegProcess.stdin.end(); + + await this.ffmpegClosingPromise; + } +} diff --git a/test/functional/assertion-helper.js b/test/functional/assertion-helper.js index bc5afa1e301..2c34fc70ce7 100644 --- a/test/functional/assertion-helper.js +++ b/test/functional/assertion-helper.js @@ -1,4 +1,5 @@ const expect = require('chai').expect; +const globby = require('globby'); const path = require('path'); const fs = require('fs'); const Promise = require('pinkie'); @@ -8,7 +9,7 @@ const pngjs = require('pngjs'); const config = require('./config.js'); -const SCREENSHOTS_PATH = '___test-screenshots___'; +const SCREENSHOTS_PATH = config.testScreenshotsDir; const THUMBNAILS_DIR_NAME = 'thumbnails'; const ERRORS_DIR_NAME = 'errors'; const TASK_DIR_RE = /\d{4,4}-\d{2,2}-\d{2,2}_\d{2,2}-\d{2,2}-\d{2,2}/; @@ -19,6 +20,8 @@ const RUN_DIR_NAME_RE = /run-\d+/; const GREEN_PIXEL = [0, 255, 0, 255]; const RED_PIXEL = [255, 0, 0, 255]; +const VIDEOS_PATH = config.testVideosDir; +const VIDEO_FILES_GLOB = path.join(VIDEOS_PATH, '**', '*'); function hasPixel (png, pixel, x, y) { const baseIndex = (png.width * y + x) * 4; @@ -311,11 +314,19 @@ exports.isScreenshotsEqual = function (customPath, referenceImagePathGetter) { }); }; -exports.removeScreenshotDir = function () { - if (isDirExists(SCREENSHOTS_PATH)) - return del(SCREENSHOTS_PATH); +function removeDir (dirPath) { + if (isDirExists(dirPath)) + return del(dirPath); return Promise.resolve(); +} + +exports.removeScreenshotDir = () => removeDir(SCREENSHOTS_PATH); + +exports.removeVideosDir = () => removeDir(VIDEOS_PATH); + +exports.getVideoFilesList = () => { + return globby(VIDEO_FILES_GLOB, { nodir: true }); }; exports.SCREENSHOTS_PATH = SCREENSHOTS_PATH; diff --git a/test/functional/config.js b/test/functional/config.js index 3f3f0f38976..00ac808648f 100644 --- a/test/functional/config.js +++ b/test/functional/config.js @@ -273,5 +273,8 @@ module.exports = { browserstackConnectorServicePort: 9200, - browsers: [] + browsers: [], + + testScreenshotsDir: '___test-screenshots___', + testVideosDir: '___test-videos___' }; diff --git a/test/functional/fixtures/api/es-next/native-dialogs-handling/iframe/test.js b/test/functional/fixtures/api/es-next/native-dialogs-handling/iframe/test.js index 00c77c79e0f..d44f4364df8 100644 --- a/test/functional/fixtures/api/es-next/native-dialogs-handling/iframe/test.js +++ b/test/functional/fixtures/api/es-next/native-dialogs-handling/iframe/test.js @@ -103,7 +103,7 @@ describe('Native dialogs handling in iframe', function () { DEFAULT_FAILED_RUN_IN_IFRAME_OPTIONS) .catch(function (errs) { errorInEachBrowserContains(errs, getNativeDialogNotHandledErrorText('alert', pageLoadingUrl), 0); - errorInEachBrowserContains(errs, '> 28 | await t.click(\'body\'); ', 0); + errorInEachBrowserContains(errs, '> 30 | await t.click(\'body\'); ', 0); }); }); }); diff --git a/test/functional/fixtures/api/es-next/native-dialogs-handling/iframe/testcafe-fixtures/page-load-test.js b/test/functional/fixtures/api/es-next/native-dialogs-handling/iframe/testcafe-fixtures/page-load-test.js index 954589c5524..24ec1ac635f 100644 --- a/test/functional/fixtures/api/es-next/native-dialogs-handling/iframe/testcafe-fixtures/page-load-test.js +++ b/test/functional/fixtures/api/es-next/native-dialogs-handling/iframe/testcafe-fixtures/page-load-test.js @@ -1,7 +1,6 @@ import { expect } from 'chai'; -fixture `Page load` - .page `http://localhost:3000/fixtures/api/es-next/native-dialogs-handling/iframe/pages/page-load.html`; +fixture `Page load`; const pageUrl = 'http://localhost:3000/fixtures/api/es-next/native-dialogs-handling/iframe/pages/page-load.html'; @@ -10,7 +9,8 @@ const iframeUrl = 'http://localhost:3000/fixtures/api/es-next/native-dialogs-han test('Expected dialogs after page load', async t => { await t - .setNativeDialogHandler(() => null); + .setNativeDialogHandler(() => null) + .navigateTo(pageUrl); // NOTE: waiting for iframe loading await t.switchToIframe('#iframe'); @@ -24,6 +24,8 @@ test('Expected dialogs after page load', async t => { ]); }); -test('Unexpected alert after page load', async t => { - await t.click('body'); -}); +test + .page(pageUrl) + ('Unexpected alert after page load', async t => { + await t.click('body'); + }); diff --git a/test/functional/fixtures/api/es-next/native-dialogs-handling/test.js b/test/functional/fixtures/api/es-next/native-dialogs-handling/test.js index 20ce5e2aac5..a46d18c6939 100644 --- a/test/functional/fixtures/api/es-next/native-dialogs-handling/test.js +++ b/test/functional/fixtures/api/es-next/native-dialogs-handling/test.js @@ -78,7 +78,7 @@ describe('Native dialogs handling', function () { { shouldFail: true }) .catch(function (errs) { errorInEachBrowserContains(errs, getNativeDialogNotHandledErrorText('alert', pageLoadingUrl), 0); - errorInEachBrowserContains(errs, '> 40 | await t.click(\'body\');', 0); + errorInEachBrowserContains(errs, '> 42 | await t.click(\'body\');', 0); }); }); }); diff --git a/test/functional/fixtures/api/es-next/native-dialogs-handling/testcafe-fixtures/page-load-test.js b/test/functional/fixtures/api/es-next/native-dialogs-handling/testcafe-fixtures/page-load-test.js index ac49df405fb..778ddfb1df3 100644 --- a/test/functional/fixtures/api/es-next/native-dialogs-handling/testcafe-fixtures/page-load-test.js +++ b/test/functional/fixtures/api/es-next/native-dialogs-handling/testcafe-fixtures/page-load-test.js @@ -1,8 +1,7 @@ import { ClientFunction } from 'testcafe'; import { expect } from 'chai'; -fixture `Page load` - .page `http://localhost:3000/fixtures/api/es-next/native-dialogs-handling/pages/page-load.html`; +fixture `Page load`; const getResult = ClientFunction(() => document.getElementById('result').textContent); @@ -16,7 +15,8 @@ test('Expected dialogs after page load', async t => { return true; return null; - }); + }) + .navigateTo(pageUrl); expect(await getResult()).equals('true'); @@ -36,6 +36,8 @@ test('Expected dialogs after page load', async t => { ]); }); -test('Unexpected alert after page load', async t => { - await t.click('body'); -}); +test + .page(pageUrl) + ('Unexpected alert after page load', async t => { + await t.click('body'); + }); diff --git a/test/functional/fixtures/video-recording/pages/index.html b/test/functional/fixtures/video-recording/pages/index.html new file mode 100644 index 00000000000..92df70d90c1 --- /dev/null +++ b/test/functional/fixtures/video-recording/pages/index.html @@ -0,0 +1,8 @@ + + + Video + + +

Video recording test

+ + diff --git a/test/functional/fixtures/video-recording/test.js b/test/functional/fixtures/video-recording/test.js new file mode 100644 index 00000000000..c137f7aa05a --- /dev/null +++ b/test/functional/fixtures/video-recording/test.js @@ -0,0 +1,103 @@ +const expect = require('chai').expect; +const config = require('../../config'); +const assertionHelper = require('../../assertion-helper.js'); + + +if (config.useLocalBrowsers) { + describe('Video Recording', () => { + afterEach(assertionHelper.removeVideosDir); + + it('Should record video without options', () => { + return runTests('./testcafe-fixtures/index-test.js', '', { + only: 'chrome,firefox', + setVideoPath: true, + shouldFail: true + }) + .then(() => { + throw new Error('Promise rejection expected'); + }) + .catch(errors => { + expect(errors.length).to.equal(2); + expect(errors[0]).to.match(/^Error: Error 1/); + expect(errors[1]).to.match(/^Error: Error 2/); + }) + .then(assertionHelper.getVideoFilesList) + .then(videoFiles => { + expect(videoFiles.length).to.equal(3 * config.browsers.length); + }); + }); + + it('Should record video in a single file', ()=> { + return runTests('./testcafe-fixtures/index-test.js', '', { + only: 'chrome,firefox', + shouldFail: true, + setVideoPath: true, + + videoOptions: { + singleFile: true + } + }) + .then(() => { + throw new Error('Promise rejection expected'); + }) + .catch(assertionHelper.getVideoFilesList) + .catch(errors => { + expect(errors.length).to.equal(2); + expect(errors[0]).to.match(/^Error: Error 1/); + expect(errors[1]).to.match(/^Error: Error 2/); + }) + .then(videoFiles => { + expect(videoFiles.length).to.equal(1 * config.browsers.length); + }); + }); + + it('Should record only failed tests', () => { + return runTests('./testcafe-fixtures/index-test.js', '', { + only: 'chrome,firefox', + shouldFail: true, + setVideoPath: true, + + videoOptions: { + failedOnly: true + } + }) + .then(() => { + throw new Error('Promise rejection expected'); + }) + .catch(assertionHelper.getVideoFilesList) + .catch(errors => { + expect(errors.length).to.equal(2); + expect(errors[0]).to.match(/^Error: Error 1/); + expect(errors[1]).to.match(/^Error: Error 2/); + }) + .then(videoFiles => { + expect(videoFiles.length).to.equal(2 * config.browsers.length); + }); + }); + + it('Should record only failed tests in a single file', () => { + return runTests('./testcafe-fixtures/index-test.js', '', { + only: 'chrome,firefox', + shouldFail: true, + setVideoPath: true, + + videoOptions: { + failedOnly: true, + singleFile: true + } + }) + .then(() => { + throw new Error('Promise rejection expected'); + }) + .catch(assertionHelper.getVideoFilesList) + .catch(errors => { + expect(errors.length).to.equal(2); + expect(errors[0]).to.match(/^Error: Error 1/); + expect(errors[1]).to.match(/^Error: Error 2/); + }) + .then(videoFiles => { + expect(videoFiles.length).to.equal(1 * config.browsers.length); + }); + }); + }); +} diff --git a/test/functional/fixtures/video-recording/testcafe-fixtures/index-test.js b/test/functional/fixtures/video-recording/testcafe-fixtures/index-test.js new file mode 100644 index 00000000000..aaf076a2a2f --- /dev/null +++ b/test/functional/fixtures/video-recording/testcafe-fixtures/index-test.js @@ -0,0 +1,18 @@ +fixture `Video` + .page `../pages/index.html`; + +test('First', async t => { + await t.wait(2000); +}); + +test('Second', async t => { + await t.wait(2000); + + throw new Error('Error 1'); +}); + +test('Third', async t => { + await t.wait(2000); + + throw new Error('Error 2'); +}); diff --git a/test/functional/setup.js b/test/functional/setup.js index 0a766b083f5..61b356a7608 100644 --- a/test/functional/setup.js +++ b/test/functional/setup.js @@ -186,9 +186,12 @@ before(function () { const pageLoadTimeout = opts && opts.pageLoadTimeout || FUNCTIONAL_TESTS_PAGE_LOAD_TIMEOUT; const onlyOption = opts && opts.only; const skipOption = opts && opts.skip; - const screenshotPath = opts && opts.setScreenshotPath ? '___test-screenshots___' : ''; + const screenshotPath = opts && opts.setScreenshotPath ? config.testScreenshotsDir : ''; const screenshotPathPattern = opts && opts.screenshotPathPattern; const screenshotsOnFails = opts && opts.screenshotsOnFails; + const videoPath = opts && opts.setVideoPath ? config.testVideosDir : ''; + const videoOptions = opts && opts.videoOptions; + const videoEncodingOptions = opts && opts.videoEncodingOptions; const speed = opts && opts.speed; const appCommand = opts && opts.appCommand; const appInitDelay = opts && opts.appInitDelay; @@ -241,6 +244,7 @@ before(function () { }) .src(fixturePath) .screenshots(screenshotPath, screenshotsOnFails, screenshotPathPattern) + .video(videoPath, videoOptions, videoEncodingOptions) .startApp(appCommand, appInitDelay) .run({ skipJsErrors, diff --git a/test/functional/site/server.js b/test/functional/site/server.js index 280adab96a4..b7ca1197e1e 100644 --- a/test/functional/site/server.js +++ b/test/functional/site/server.js @@ -19,6 +19,13 @@ const CONTENT_TYPES = { '.png': 'image/png' }; +const NON_CACHEABLE_PAGES = [ + '/fixtures/api/es-next/roles/pages', + '/fixtures/api/es-next/request-hooks/pages', + '/fixtures/regression/gh-2015/pages', + '/fixtures/regression/gh-2282/pages' +]; + const UPLOAD_SUCCESS_PAGE_TEMPLATE = readSync('./views/upload-success.html.mustache'); @@ -66,7 +73,7 @@ Server.prototype._setupRoutes = function () { .then(function (content) { res.setHeader('content-type', CONTENT_TYPES[path.extname(resourcePath)]); - if (!reqPath.startsWith('/fixtures/api/es-next/roles/pages') && !reqPath.startsWith('/fixtures/api/es-next/request-hooks/pages')) + if (NON_CACHEABLE_PAGES.every(pagePrefix => !reqPath.startsWith(pagePrefix))) res.setHeader('cache-control', 'max-age=3600'); setTimeout(function () { diff --git a/test/server/cli-argument-parser-test.js b/test/server/cli-argument-parser-test.js index 03047f67604..2a7ddde891f 100644 --- a/test/server/cli-argument-parser-test.js +++ b/test/server/cli-argument-parser-test.js @@ -406,9 +406,40 @@ describe('CLI argument parser', function () { expect(parser.opts.ssl.key).eql(keyFileContent); }); }); + + it('Should throw an error if a file is not readable', () => { + return parse(`--ssl key=${__dirname}`) + .catch(error => { + expect(error.message).to.include( + `Unable to read the "${__dirname}" file, specified by the "key" ssl option. Error details:` + ); + }) + .then(() => parse(`--ssl cert=${__dirname}`)) + .catch(error => { + expect(error.message).to.include( + `Unable to read the "${__dirname}" file, specified by the "cert" ssl option. Error details:` + ); + }) + .then(() => parse(`--ssl pfx=${__dirname}`)) + .catch(error => { + expect(error.message).to.include( + `Unable to read the "${__dirname}" file, specified by the "pfx" ssl option. Error details:` + ); + }); + }); }); }); + it('Should parse video recording options', () => { + return parse(`--video /home/user/video --video-options singleFile=true,failedOnly --video-encoding-options c:v=x264`) + .then(parser => { + expect(parser.opts.video).eql('/home/user/video'); + expect(parser.opts.videoOptions.singleFile).eql(true); + expect(parser.opts.videoOptions.failedOnly).eql(true); + expect(parser.opts.videoEncodingOptions['c:v']).eql('x264'); + }); + }); + it('Should parse reporters and their output file paths and ensure they exist', function () { const cwd = process.cwd(); const filePath = path.join(tmp.dirSync().name, 'my/reports/report.json'); @@ -484,7 +515,10 @@ describe('CLI argument parser', function () { { long: '--color' }, { long: '--no-color' }, { long: '--stop-on-first-fail', short: '--sf' }, - { long: '--disable-test-syntax-validation' } + { long: '--disable-test-syntax-validation' }, + { long: '--video' }, + { long: '--video-options' }, + { long: '--video-encoding-options' } ]; const parser = new CliArgumentParser(''); diff --git a/test/server/configuration-test.js b/test/server/configuration-test.js index 792ac4c98ed..e5f6a7287d5 100644 --- a/test/server/configuration-test.js +++ b/test/server/configuration-test.js @@ -11,9 +11,10 @@ const consoleWrapper = require('./helpers/console-wrapper'); describe('Configuration', () => { let configuration = null; let configPath = null; - const savedConsoleLog = console.log; let keyFileContent = null; + consoleWrapper.init(); + tmp.setGracefulCleanup(); const createConfigFile = options => { @@ -53,6 +54,7 @@ describe('Configuration', () => { if (fs.existsSync(configPath)) fs.unlinkSync(configPath); + consoleWrapper.unwrap(); consoleWrapper.messages.clear(); }); @@ -60,11 +62,11 @@ describe('Configuration', () => { describe('Exists', () => { it('Config is not well-formed', () => { fs.writeFileSync(configPath, '{'); - console.log = consoleWrapper.log; + consoleWrapper.wrap(); return configuration.init() .then(() => { - console.log = savedConsoleLog; + consoleWrapper.unwrap(); expect(configuration.getOption('hostname')).eql(void 0); expect(consoleWrapper.messages.log).contains("Failed to parse the '.testcaferc.json' file."); @@ -149,21 +151,22 @@ describe('Configuration', () => { describe('Merge options', () => { it('One', () => { - console.log = consoleWrapper.log; + consoleWrapper.wrap(); return configuration.init() .then(() => { configuration.mergeOptions({ 'hostname': 'anotherHostname' }); configuration.notifyAboutOverridenOptions(); - console.log = savedConsoleLog; + + consoleWrapper.unwrap(); expect(configuration.getOption('hostname')).eql('anotherHostname'); - expect(consoleWrapper.messages.log).eql('The "hostname" option from the configuration file will be ignored.'); + expect(consoleWrapper.messages.log).eql('The "hostname" option from the configuration file will be ignored.\n'); }); }); it('Many', () => { - console.log = consoleWrapper.log; + consoleWrapper.wrap(); return configuration.init() .then(() => { @@ -175,12 +178,12 @@ describe('Configuration', () => { configuration.notifyAboutOverridenOptions(); - console.log = savedConsoleLog; + consoleWrapper.unwrap(); expect(configuration.getOption('hostname')).eql('anotherHostname'); expect(configuration.getOption('port1')).eql('anotherPort1'); expect(configuration.getOption('port2')).eql('anotherPort2'); - expect(consoleWrapper.messages.log).eql('The "hostname", "port1", "port2" options from the configuration file will be ignored.'); + expect(consoleWrapper.messages.log).eql('The "hostname", "port1", "port2" options from the configuration file will be ignored.\n'); }); }); diff --git a/test/server/helpers/console-wrapper.js b/test/server/helpers/console-wrapper.js index 9015ab2c9c3..924471f5dfb 100644 --- a/test/server/helpers/console-wrapper.js +++ b/test/server/helpers/console-wrapper.js @@ -1,14 +1,29 @@ -const wrapper = { +module.exports = { + originalLogFunction: null, + messages: { - log: null, - clear: function () { + log: null, + + clear () { this.log = null; } - } -}; + }, -wrapper.log = (...args) => { - wrapper.messages.log = args.join(); -}; + init () { + this.originalLogFunction = process.stdout.write; + }, -module.exports = wrapper; + wrap () { + process.stdout.write = this.log; + }, + + unwrap () { + process.stdout.write = this.originalLogFunction; + + }, + + // NOTE: We can't write `wrapper.log` as a method and use `this` inside it because it will replace a method of another object + log: (...args) => { + module.exports.messages.log = args.join(); + } +}; diff --git a/test/server/path-pattern-test.js b/test/server/path-pattern-test.js index b90e8a99853..67aafcc8a8b 100644 --- a/test/server/path-pattern-test.js +++ b/test/server/path-pattern-test.js @@ -2,52 +2,45 @@ const path = require('path'); const expect = require('chai').expect; const moment = require('moment'); const userAgent = require('useragent'); -const PathPattern = require('../../lib/screenshots/path-pattern'); +const PathPattern = require('../../lib/utils/path-pattern'); +const SCREENSHOT_EXTENSION = 'png'; + describe('Screenshot path pattern', () => { - const parsedUserAgentMock = { - toVersion: () => {}, - os: { toVersion: () => {} } - }; + const TEST_USER_AGENT = 'Mozilla/5.0 (Windows NT 6.3; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/68.0.3440.106 Safari/537.36'; - const createPathPattern = (pattern, data) => { - data = data || {}; - data.now = data.now || moment(); - data.parsedUserAgent = data.parsedUserAgent || parsedUserAgentMock; - data.quarantineAttempt = data.quarantineAttempt || null; + const createPathPatternData = ({ forQuarantine }) => ({ + now: moment('2010-01-02 11:12:13'), + testIndex: 12, + fileIndex: 34, + quarantineAttempt: forQuarantine ? 2 : null, + fixture: 'fixture', + test: 'test', + parsedUserAgent: userAgent.parse(TEST_USER_AGENT) + }); - return new PathPattern(pattern, data); + const createPathPattern = (pattern, { forQuarantine } = {}) => { + return new PathPattern(pattern, SCREENSHOT_EXTENSION, createPathPatternData({ forQuarantine })); }; describe('Default pattern', () => { it('Normal run', () => { const pathPattern = createPathPattern(); - expect(pathPattern.pattern).eql('${DATE}_${TIME}\\test-${TEST_INDEX}\\${USERAGENT}\\${FILE_INDEX}.png'); + expect(pathPattern.getPath()).match(/2010-01-02_11-12-13[\\/]test-12[\\/]Chrome_68.0.3440_Windows_8.1.0.0[\\/]34.png/); }); it('Quarantine mode', () => { - const pathPattern = createPathPattern(void 0, { quarantineAttempt: 1 }); + const pathPattern = createPathPattern(void 0, { forQuarantine: true }); - expect(pathPattern.pattern).eql('${DATE}_${TIME}\\test-${TEST_INDEX}\\run-${QUARANTINE_ATTEMPT}\\${USERAGENT}\\${FILE_INDEX}.png'); + expect(pathPattern.getPath()).match(/2010-01-02_11-12-13[\\/]test-12[\\/]run-2[\\/]Chrome_68.0.3440_Windows_8.1.0.0[\\/]34.png/); }); }); it('Should replace all placeholders', () => { const pattern = Object.getOwnPropertyNames(PathPattern.PLACEHOLDERS).map(name => PathPattern.PLACEHOLDERS[name]).join('#'); - const dateStr = '2010-01-02'; - const timeStr = '11:12:13'; - const parsedUserAgent = userAgent.parse('Mozilla/5.0 (Windows NT 6.3; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/68.0.3440.106 Safari/537.36'); - const data = { - now: moment(dateStr + ' ' + timeStr), - testIndex: 12, - fileIndex: 34, - quarantineAttempt: 2, - fixture: 'fixture', - test: 'test', - parsedUserAgent - }; + const expectedParsedPattern = [ '2010-01-02', '11-12-13', @@ -60,10 +53,12 @@ describe('Screenshot path pattern', () => { 'Chrome', '68.0.3440', 'Windows', - '8.1.0.0' + '8.1.0.0', + 'test-12', + 'run-2' ].join('#') + '.png'; - const pathPattern = createPathPattern(pattern, data); + const pathPattern = createPathPattern(pattern, { forQuarantine: true }); const resultPath = pathPattern.getPath(false); diff --git a/test/server/runner-test.js b/test/server/runner-test.js index a05f53a0bc0..e25c94b5745 100644 --- a/test/server/runner-test.js +++ b/test/server/runner-test.js @@ -296,6 +296,36 @@ describe('Runner', () => { }); }); + describe('.video()', () => { + it('Should throw an error if video options are specified without a base video path', () => { + return runner + .browsers(connection) + .video(void 0, { failedOnly: true }) + .src('test/server/data/test-suites/basic/testfile2.js') + .run() + .then(() => { + throw new Error('Promise rejection expected'); + }) + .catch(err => { + expect(err.message).eql('Unable to set video or encoding options when video recording is disabled. Specify the base path where video files are stored to enable recording.'); + }); + }); + + it('Should throw an error if video encoding options are specified without a base video path', () => { + return runner + .browsers(connection) + .video(void 0, null, { 'c:v': 'x264' }) + .src('test/server/data/test-suites/basic/testfile2.js') + .run() + .then(() => { + throw new Error('Promise rejection expected'); + }) + .catch(err => { + expect(err.message).eql('Unable to set video or encoding options when video recording is disabled. Specify the base path where video files are stored to enable recording.'); + }); + }); + }); + describe('.src()', () => { it('Should accept source files in different forms', () => { const cwd = process.cwd(); From 57bd122d12fb13f4bc2cda9f0b3d96a2661ed155 Mon Sep 17 00:00:00 2001 From: Andrey Belym Date: Thu, 24 Jan 2019 17:19:10 +0300 Subject: [PATCH 2/3] Change 'cant' to cannot --- src/configuration/index.js | 6 +++--- src/errors/runtime/message.js | 18 +++++++++--------- src/notifications/warning-message.js | 16 ++++++++-------- src/runner/index.js | 4 ++-- src/utils/get-options/ssl.js | 4 ++-- 5 files changed, 24 insertions(+), 24 deletions(-) diff --git a/src/configuration/index.js b/src/configuration/index.js index 609f7149257..7057f336c7e 100644 --- a/src/configuration/index.js +++ b/src/configuration/index.js @@ -62,7 +62,7 @@ export default class Configuration { return true; } catch (error) { - DEBUG_LOGGER(renderTemplate(WARNING_MESSAGES.cantFindConfigurationFile, path, error.stack)); + DEBUG_LOGGER(renderTemplate(WARNING_MESSAGES.cannotFindConfigurationFile, path, error.stack)); return false; } @@ -91,7 +91,7 @@ export default class Configuration { configurationFileContent = await readFile(this.filePath); } catch (error) { - Configuration._showWarningForError(error, WARNING_MESSAGES.cantReadConfigFile); + Configuration._showWarningForError(error, WARNING_MESSAGES.cannotReadConfigFile); return; } @@ -102,7 +102,7 @@ export default class Configuration { this._options = Configuration._fromObj(optionsObj); } catch (error) { - Configuration._showWarningForError(error, WARNING_MESSAGES.cantParseConfigFile); + Configuration._showWarningForError(error, WARNING_MESSAGES.cannotParseConfigFile); return; } diff --git a/src/errors/runtime/message.js b/src/errors/runtime/message.js index f8bdad9b169..847362632e4 100644 --- a/src/errors/runtime/message.js +++ b/src/errors/runtime/message.js @@ -35,13 +35,13 @@ export default { testControllerProxyCantResolveTestRun: `Cannot implicitly resolve the test run in the context of which the test controller action should be executed. Use test function's 't' argument instead.`, timeLimitedPromiseTimeoutExpired: 'Timeout expired for a time limited promise', cantUseScreenshotPathPatternWithoutBaseScreenshotPathSpecified: 'Cannot use the screenshot path pattern without a base screenshot path specified', - cantSetVideoOptionsWithoutBaseVideoPathSpecified: 'Unable to set video or encoding options when video recording is disabled. Specify the base path where video files are stored to enable recording.', + cannotSetVideoOptionsWithoutBaseVideoPathSpecified: 'Unable to set video or encoding options when video recording is disabled. Specify the base path where video files are stored to enable recording.', multipleAPIMethodCallForbidden: 'You cannot call the "{methodName}" method more than once. Pass an array of parameters to this method instead.', invalidReporterOutput: "Specify a file name or a writable stream as the reporter's output target.", - cantReadSSLCertFile: 'Unable to read the "{path}" file, specified by the "{option}" ssl option. Error details:\n' + - '\n' + - '{err}', + cannotReadSSLCertFile: 'Unable to read the "{path}" file, specified by the "{option}" ssl option. Error details:\n' + + '\n' + + '{err}', cannotPrepareTestsDueToError: 'Cannot prepare tests due to an error.\n' + '\n' + @@ -66,9 +66,9 @@ export default { forbiddenCharatersInScreenshotPath: 'There are forbidden characters in the "{screenshotPath}" {screenshotPathType}:\n' + ' {forbiddenCharsDescription}', - cantFindFFMPEG: 'Unable to locate the FFmpeg executable required to record videos. Do one of the following:\n' + - '\n' + - '* add the FFmpeg installation directory to the PATH environment variable,\n' + - '* specify the path to the FFmpeg executable in the FFMPEG_PATH environment variable or the ffmpegPath video option,\n' + - '* install the @ffmpeg-installer/ffmpeg package from npm.', + cannotFindFFMPEG: 'Unable to locate the FFmpeg executable required to record videos. Do one of the following:\n' + + '\n' + + '* add the FFmpeg installation directory to the PATH environment variable,\n' + + '* specify the path to the FFmpeg executable in the FFMPEG_PATH environment variable or the ffmpegPath video option,\n' + + '* install the @ffmpeg-installer/ffmpeg package from npm.', }; diff --git a/src/notifications/warning-message.js b/src/notifications/warning-message.js index 61fda5be46b..ed127b50bbb 100644 --- a/src/notifications/warning-message.js +++ b/src/notifications/warning-message.js @@ -12,15 +12,15 @@ export default { maximizeError: 'Was unable to maximize the window due to an error.\n\n{errMessage}', requestMockCORSValidationFailed: '{RequestHook}: CORS validation failed for a request specified as {requestFilterRule}', debugInHeadlessError: 'You cannot debug in headless mode.', - cantReadConfigFile: 'An error has occurred while reading the configuration file.', - cantParseConfigFile: "Failed to parse the '.testcaferc.json' file.\\n\\nThis file is not a well-formed JSON file.", + cannotReadConfigFile: 'An error has occurred while reading the configuration file.', + cannotParseConfigFile: "Failed to parse the '.testcaferc.json' file.\\n\\nThis file is not a well-formed JSON file.", configOptionsWereOverriden: 'The {optionsString} option{suffix} from the configuration file will be ignored.', - cantFindSSLCertFile: 'Unable to find the "{path}" file, specified by the "{option}" ssl option. Error details:\n' + - '\n' + - '{err}', + cannotFindSSLCertFile: 'Unable to find the "{path}" file, specified by the "{option}" ssl option. Error details:\n' + + '\n' + + '{err}', - cantFindConfigurationFile: 'Unable to find the "{path}" configuration file. Error details:\n' + - '\n' + - '{err}' + cannotFindConfigurationFile: 'Unable to find the "{path}" configuration file. Error details:\n' + + '\n' + + '{err}' }; diff --git a/src/runner/index.js b/src/runner/index.js index 493fa95466a..61b513135f0 100644 --- a/src/runner/index.js +++ b/src/runner/index.js @@ -238,7 +238,7 @@ export default class Runner extends EventEmitter { if (!videoPath) { if (videoOptions || videoEncodingOptions) - throw new GeneralError(MESSAGE.cantSetVideoOptionsWithoutBaseVideoPathSpecified); + throw new GeneralError(MESSAGE.cannotSetVideoOptionsWithoutBaseVideoPathSpecified); return; } @@ -257,7 +257,7 @@ export default class Runner extends EventEmitter { videoOptions.ffmpegPath = await detectFFMPEG(); if (!videoOptions.ffmpegPath) - throw new GeneralError(MESSAGE.cantFindFFMPEG); + throw new GeneralError(MESSAGE.cannotFindFFMPEG); } async _validateRunOptions () { diff --git a/src/utils/get-options/ssl.js b/src/utils/get-options/ssl.js index 995c08047a1..a6420e9e025 100644 --- a/src/utils/get-options/ssl.js +++ b/src/utils/get-options/ssl.js @@ -34,7 +34,7 @@ export default function (optionString) { await stat(value); } catch (error) { - DEBUG_LOGGER(renderTemplate(WARNING_MESSAGES.cantFindSSLCertFile, value, key, error.stack)); + DEBUG_LOGGER(renderTemplate(WARNING_MESSAGES.cannotFindSSLCertFile, value, key, error.stack)); return value; } @@ -43,7 +43,7 @@ export default function (optionString) { return await readFile(value); } catch (error) { - throw new GeneralError(ERROR_MESSAGES.cantReadSSLCertFile, value, key, error.stack); + throw new GeneralError(ERROR_MESSAGES.cannotReadSSLCertFile, value, key, error.stack); } } }); From 7866642b31b4f06c980a08ee7e50145c77f33244 Mon Sep 17 00:00:00 2001 From: Andrey Belym Date: Thu, 24 Jan 2019 17:39:01 +0300 Subject: [PATCH 3/3] Fix remaining remarks --- src/browser/provider/plugin-host.js | 4 +++- src/browser/provider/pool.js | 2 +- src/notifications/warning-message.js | 2 +- src/video-recorder/index.js | 2 +- test/functional/fixtures/video-recording/test.js | 12 ------------ 5 files changed, 6 insertions(+), 16 deletions(-) diff --git a/src/browser/provider/plugin-host.js b/src/browser/provider/plugin-host.js index 62b1e3327e0..ba9fc9dd4d6 100644 --- a/src/browser/provider/plugin-host.js +++ b/src/browser/provider/plugin-host.js @@ -133,7 +133,9 @@ export default class BrowserProviderPluginHost { } async getVideoFrameData (browserId) { - this.reportWarning(browserId, WARNING_MESSAGE.videoNotSupportedByBrowserProvider, this[name]); + const browserAlias = BrowserConnection.getById(browserId).browserInfo.alias; + + this.reportWarning(browserId, WARNING_MESSAGE.videoNotSupportedByBrowserProvider, browserAlias); } async reportJobResult (/*browserId, status, data*/) { diff --git a/src/browser/provider/pool.js b/src/browser/provider/pool.js index a3e73ff9077..f8ed50e14bf 100644 --- a/src/browser/provider/pool.js +++ b/src/browser/provider/pool.js @@ -106,7 +106,7 @@ export default { if (!await provider.isValidBrowserName(browserName)) throw new GeneralError(MESSAGE.cantFindBrowser, alias); - return browserInfo; + return { alias, ...browserInfo }; }, addProvider (providerName, providerObject) { diff --git a/src/notifications/warning-message.js b/src/notifications/warning-message.js index ed127b50bbb..0a64b8cd2de 100644 --- a/src/notifications/warning-message.js +++ b/src/notifications/warning-message.js @@ -5,7 +5,7 @@ export default { screenshotRewritingError: 'The file at "{screenshotPath}" already exists. It has just been rewritten with a recent screenshot. This situation can possibly cause issues. To avoid them, make sure that each screenshot has a unique path. If a test runs in multiple browsers, consider including the user agent in the screenshot path or generate a unique identifier in another way.', browserManipulationsOnRemoteBrowser: 'The screenshot and window resize functionalities are not supported in a remote browser. They can function only if the browser is running on the same machine and in the same environment as the TestCafe server.', screenshotNotSupportedByBrowserProvider: 'The screenshot functionality is not supported by the "{providerName}" browser provider.', - videoNotSupportedByBrowserProvider: 'The video recording functionality is not supported by the "{providerName}" browser provider.', + videoNotSupportedByBrowserProvider: 'The video recording functionality is not supported by the "{browserAlias}" browser.', resizeNotSupportedByBrowserProvider: 'The window resize functionality is not supported by the "{providerName}" browser provider.', maximizeNotSupportedByBrowserProvider: 'The window maximization functionality is not supported by the "{providerName}" browser provider.', resizeError: 'Was unable to resize the window due to an error.\n\n{errMessage}', diff --git a/src/video-recorder/index.js b/src/video-recorder/index.js index 0cf39f378fe..56ddcaac4e2 100644 --- a/src/video-recorder/index.js +++ b/src/video-recorder/index.js @@ -124,7 +124,7 @@ export default class VideoRecorder { const connectionCapabilities = await testRun.browserConnection.provider.hasCustomActionForBrowser(connection.id); if (!connectionCapabilities || !connectionCapabilities.hasGetVideoFrameData) { - this.browserJob.warningLog.addWarning(WARNING_MESSAGES.videoNotSupportedByBrowserProvider, connection.browserInfo.providerName); + this.browserJob.warningLog.addWarning(WARNING_MESSAGES.videoNotSupportedByBrowserProvider, connection.browserInfo.alias); return; } diff --git a/test/functional/fixtures/video-recording/test.js b/test/functional/fixtures/video-recording/test.js index c137f7aa05a..02359406878 100644 --- a/test/functional/fixtures/video-recording/test.js +++ b/test/functional/fixtures/video-recording/test.js @@ -13,9 +13,6 @@ if (config.useLocalBrowsers) { setVideoPath: true, shouldFail: true }) - .then(() => { - throw new Error('Promise rejection expected'); - }) .catch(errors => { expect(errors.length).to.equal(2); expect(errors[0]).to.match(/^Error: Error 1/); @@ -37,9 +34,6 @@ if (config.useLocalBrowsers) { singleFile: true } }) - .then(() => { - throw new Error('Promise rejection expected'); - }) .catch(assertionHelper.getVideoFilesList) .catch(errors => { expect(errors.length).to.equal(2); @@ -61,9 +55,6 @@ if (config.useLocalBrowsers) { failedOnly: true } }) - .then(() => { - throw new Error('Promise rejection expected'); - }) .catch(assertionHelper.getVideoFilesList) .catch(errors => { expect(errors.length).to.equal(2); @@ -86,9 +77,6 @@ if (config.useLocalBrowsers) { singleFile: true } }) - .then(() => { - throw new Error('Promise rejection expected'); - }) .catch(assertionHelper.getVideoFilesList) .catch(errors => { expect(errors.length).to.equal(2);