diff --git a/Gulpfile.js b/Gulpfile.js index c4eeeb0e1e4..4edf7d907d9 100644 --- a/Gulpfile.js +++ b/Gulpfile.js @@ -180,6 +180,7 @@ gulp.task('lint', () => { 'src/**/*.ts', 'test/**/*.js', '!test/client/vendor/**/*.*', + '!test/functional/fixtures/api/es-next/custom-client-scripts/data/*.js', 'Gulpfile.js' ]) .pipe(eslint()) diff --git a/package.json b/package.json index 4c565916052..6ead1fd35a4 100644 --- a/package.json +++ b/package.json @@ -114,7 +114,7 @@ "source-map-support": "^0.5.5", "strip-bom": "^2.0.0", "testcafe-browser-tools": "1.6.8", - "testcafe-hammerhead": "14.6.13", + "testcafe-hammerhead": "14.7.0", "testcafe-legacy-api": "3.1.11", "testcafe-reporter-json": "^2.1.0", "testcafe-reporter-list": "^2.1.0", diff --git a/src/api/structure/fixture.js b/src/api/structure/fixture.js index 4e25e1c1482..bcb410f5597 100644 --- a/src/api/structure/fixture.js +++ b/src/api/structure/fixture.js @@ -3,8 +3,12 @@ import handleTagArgs from '../../utils/handle-tag-args'; import TestingUnit from './testing-unit'; import wrapTestFunction from '../wrap-test-function'; import assertRequestHookType from '../request-hooks/assert-type'; +import assertClientScriptType from '../../custom-client-scripts/assert-type'; import { flattenDeep as flatten } from 'lodash'; import { SPECIAL_BLANK_PAGE } from 'testcafe-hammerhead'; +import { APIError } from '../../errors/runtime'; +import OPTION_NAMES from '../../configuration/option-names'; +import { RUNTIME_ERRORS } from '../../errors/types'; export default class Fixture extends TestingUnit { constructor (testFile) { @@ -20,8 +24,6 @@ export default class Fixture extends TestingUnit { this.beforeFn = null; this.afterFn = null; - this.requestHooks = []; - return this.apiOrigin; } @@ -69,12 +71,32 @@ export default class Fixture extends TestingUnit { } _requestHooks$ (...hooks) { + if (this.apiMethodWasCalled.requestHooks) + throw new APIError(OPTION_NAMES.requestHooks, RUNTIME_ERRORS.multipleAPIMethodCallForbidden, OPTION_NAMES.requestHooks); + hooks = flatten(hooks); assertRequestHookType(hooks); this.requestHooks = hooks; + this.apiMethodWasCalled.requestHooks = true; + + return this.apiOrigin; + } + + _clientScripts$ (...scripts) { + if (this.apiMethodWasCalled.clientScripts) + throw new APIError(OPTION_NAMES.clientScripts, RUNTIME_ERRORS.multipleAPIMethodCallForbidden, OPTION_NAMES.clientScripts); + + scripts = flatten(scripts); + + assertClientScriptType(scripts); + + this.clientScripts = scripts; + + this.apiMethodWasCalled.clientScripts = true; + return this.apiOrigin; } } diff --git a/src/api/structure/test.js b/src/api/structure/test.js index f00c3431718..4781501e56e 100644 --- a/src/api/structure/test.js +++ b/src/api/structure/test.js @@ -2,7 +2,11 @@ import TestingUnit from './testing-unit'; import { assertType, is } from '../../errors/runtime/type-assertions'; import wrapTestFunction from '../wrap-test-function'; import assertRequestHookType from '../request-hooks/assert-type'; +import assertClientScriptType from '../../custom-client-scripts/assert-type'; import { flattenDeep as flatten, union } from 'lodash'; +import { RUNTIME_ERRORS } from '../../errors/types'; +import { APIError } from '../../errors/runtime'; +import OPTION_NAMES from '../../configuration/option-names'; export default class Test extends TestingUnit { constructor (testFile) { @@ -10,10 +14,9 @@ export default class Test extends TestingUnit { this.fixture = testFile.currentFixture; - this.fn = null; - this.beforeFn = null; - this.afterFn = null; - this.requestHooks = []; + this.fn = null; + this.beforeFn = null; + this.afterFn = null; return this.apiOrigin; } @@ -23,11 +26,12 @@ export default class Test extends TestingUnit { assertType(is.function, 'apiOrigin', 'The test body', fn); assertType(is.nonNullObject, 'apiOrigin', `The fixture of '${name}' test`, this.fixture); - this.name = name; - this.fn = wrapTestFunction(fn); - this.requestHooks = union(this.requestHooks, Array.from(this.fixture.requestHooks)); + this.name = name; + this.fn = wrapTestFunction(fn); + this.requestHooks = union(Array.from(this.fixture.requestHooks), this.requestHooks); + this.clientScripts = union(Array.from(this.fixture.clientScripts), this.clientScripts); - if (this.testFile.collectedTests.indexOf(this) < 0) + if (!this.testFile.collectedTests.includes(this)) this.testFile.collectedTests.push(this); return this.apiOrigin; @@ -50,11 +54,31 @@ export default class Test extends TestingUnit { } _requestHooks$ (...hooks) { + if (this.apiMethodWasCalled.requestHooks) + throw new APIError(OPTION_NAMES.requestHooks, RUNTIME_ERRORS.multipleAPIMethodCallForbidden, OPTION_NAMES.requestHooks); + hooks = flatten(hooks); assertRequestHookType(hooks); - this.requestHooks = union(this.requestHooks, hooks); + this.requestHooks = hooks; + + this.apiMethodWasCalled.requestHooks = true; + + return this.apiOrigin; + } + + _clientScripts$ (...scripts) { + if (this.apiMethodWasCalled.clientScripts) + throw new APIError(OPTION_NAMES.clientScripts, RUNTIME_ERRORS.multipleAPIMethodCallForbidden, OPTION_NAMES.clientScripts); + + scripts = flatten(scripts); + + assertClientScriptType(scripts); + + this.clientScripts = scripts; + + this.apiMethodWasCalled.clientScripts = true; return this.apiOrigin; } diff --git a/src/api/structure/testing-unit.js b/src/api/structure/testing-unit.js index 1b3866bb79b..5a47fd58d87 100644 --- a/src/api/structure/testing-unit.js +++ b/src/api/structure/testing-unit.js @@ -2,7 +2,8 @@ import { assertUrl, resolvePageUrl } from '../test-page-url'; import handleTagArgs from '../../utils/handle-tag-args'; import { delegateAPI, getDelegatedAPIList } from '../../utils/delegated-api'; import { assertType, is } from '../../errors/runtime/type-assertions'; - +import FlagList from '../../utils/flag-list'; +import OPTION_NAMES from '../../configuration/option-names'; export default class TestingUnit { constructor (testFile, unitTypeName) { @@ -15,9 +16,13 @@ export default class TestingUnit { this.meta = {}; this.only = false; this.skip = false; + this.requestHooks = []; + this.clientScripts = []; this.disablePageReloads = void 0; + this.apiMethodWasCalled = new FlagList([OPTION_NAMES.clientScripts, OPTION_NAMES.requestHooks]); + const unit = this; this.apiOrigin = function apiOrigin (...args) { diff --git a/src/assets/content-types.js b/src/assets/content-types.js new file mode 100644 index 00000000000..e9db9e3ae38 --- /dev/null +++ b/src/assets/content-types.js @@ -0,0 +1,6 @@ +export default { + javascript: 'application/x-javascript', + css: 'text/css', + png: 'image/png', + icon: 'image/x-icon' +}; diff --git a/src/assets/injectables.js b/src/assets/injectables.js new file mode 100644 index 00000000000..cdaec73ae17 --- /dev/null +++ b/src/assets/injectables.js @@ -0,0 +1,18 @@ +export const TESTCAFE_CORE = '/testcafe-core.js'; +export const TESTCAFE_DRIVER = '/testcafe-driver.js'; +export const TESTCAFE_LEGACY_RUNNER = '/testcafe-legacy-runner.js'; +export const TESTCAFE_AUTOMATION = '/testcafe-automation.js'; +export const TESTCAFE_UI = '/testcafe-ui.js'; + +export const SCRIPTS = [ + TESTCAFE_CORE, + TESTCAFE_UI, + TESTCAFE_AUTOMATION, + TESTCAFE_DRIVER +]; + +export const TESTCAFE_UI_SPRITE = '/testcafe-ui-sprite.png'; + +export const TESTCAFE_ICON = '/favicon.ico'; + +export const TESTCAFE_UI_STYLES = '/testcafe-ui-styles.css'; diff --git a/src/cli/argument-parser.js b/src/cli/argument-parser.js index 1a4b9aa8a71..4cb4346ff49 100644 --- a/src/cli/argument-parser.js +++ b/src/cli/argument-parser.js @@ -97,12 +97,17 @@ export default class CLIArgumentParser { .option('--qr-code', 'outputs QR-code that repeats URLs used to connect the remote browsers') .option('--sf, --stop-on-first-fail', 'stop an entire test run if any test fails') .option('--ts-config-path ', 'use a custom TypeScript configuration file and specify its location') + .option('--cs, --client-scripts ', 'inject scripts into tested pages', this._parseList, []) // NOTE: these options will be handled by chalk internally .option('--color', 'force colors in command line') .option('--no-color', 'disable colors in command line'); } + _parseList (val) { + return val.split(','); + } + _filterAndCountRemotes (browser) { const remoteMatch = browser.match(REMOTE_ALIAS_RE); diff --git a/src/cli/cli.js b/src/cli/cli.js index 7c7d5bdd099..f7aa5674499 100644 --- a/src/cli/cli.js +++ b/src/cli/cli.js @@ -103,7 +103,8 @@ async function runTests (argParser) { .filter(argParser.filter) .video(opts.video, opts.videoOptions, opts.videoEncodingOptions) .screenshots(opts.screenshots, opts.screenshotsOnFails, opts.screenshotPathPattern) - .startApp(opts.app, opts.appInitDelay); + .startApp(opts.app, opts.appInitDelay) + .clientScripts(argParser.opts.clientScripts); runner.once('done-bootstrapping', () => log.hideSpinner()); diff --git a/src/client/driver/driver.js b/src/client/driver/driver.js index cb3ba00d58e..1e1017a9695 100644 --- a/src/client/driver/driver.js +++ b/src/client/driver/driver.js @@ -39,7 +39,9 @@ import { CurrentIframeIsNotLoadedError, CurrentIframeNotFoundError, CurrentIframeIsInvisibleError, - CannotObtainInfoForElementSpecifiedBySelectorError + CannotObtainInfoForElementSpecifiedBySelectorError, + UncaughtErrorInCustomClientScriptCode, + UncaughtErrorInCustomClientScriptLoadedFromModule } from '../../errors/test-run'; import BrowserConsoleMessages from '../../test-run/browser-console-messages'; @@ -194,6 +196,15 @@ export default class Driver { return false; } + onCustomClientScriptError (err, moduleName) { + const error = moduleName + ? new UncaughtErrorInCustomClientScriptLoadedFromModule(err, moduleName) + : new UncaughtErrorInCustomClientScriptCode(err); + + if (!this.contextStorage.getItem(PENDING_PAGE_ERROR)) + this.contextStorage.setItem(PENDING_PAGE_ERROR, error); + } + // Console messages _onConsoleMessage ({ meth, line }) { const messages = this.consoleMessages; diff --git a/src/client/driver/index.js b/src/client/driver/index.js index 299f7ab27dd..000b221ea58 100644 --- a/src/client/driver/index.js +++ b/src/client/driver/index.js @@ -3,17 +3,18 @@ import Driver from './driver'; import IframeDriver from './iframe-driver'; import ScriptExecutionBarrier from './script-execution-barrier'; import embeddingUtils from './embedding-utils'; +import INTERNAL_PROPERTIES from './internal-properties'; const nativeMethods = hammerhead.nativeMethods; const evalIframeScript = hammerhead.EVENTS.evalIframeScript; -nativeMethods.objectDefineProperty(window, '%testCafeDriver%', { configurable: true, value: Driver }); -nativeMethods.objectDefineProperty(window, '%testCafeIframeDriver%', { configurable: true, value: IframeDriver }); -nativeMethods.objectDefineProperty(window, '%ScriptExecutionBarrier%', { +nativeMethods.objectDefineProperty(window, INTERNAL_PROPERTIES.testCafeDriver, { configurable: true, value: Driver }); +nativeMethods.objectDefineProperty(window, INTERNAL_PROPERTIES.testCafeIframeDriver, { configurable: true, value: IframeDriver }); +nativeMethods.objectDefineProperty(window, INTERNAL_PROPERTIES.scriptExecutionBarrier, { configurable: true, value: ScriptExecutionBarrier }); -nativeMethods.objectDefineProperty(window, '%testCafeEmbeddingUtils%', { configurable: true, value: embeddingUtils }); +nativeMethods.objectDefineProperty(window, INTERNAL_PROPERTIES.testCafeEmbeddingUtils, { configurable: true, value: embeddingUtils }); // eslint-disable-next-line no-undef hammerhead.on(evalIframeScript, e => initTestCafeClientDrivers(nativeMethods.contentWindowGetter.call(e.iframe), true)); diff --git a/src/client/driver/internal-properties.js b/src/client/driver/internal-properties.js new file mode 100644 index 00000000000..aed15d417b7 --- /dev/null +++ b/src/client/driver/internal-properties.js @@ -0,0 +1,7 @@ +export default { + testCafeDriver: '%testCafeDriver%', + testCafeIframeDriver: '%testCafeIframeDriver%', + scriptExecutionBarrier: '%ScriptExecutionBarrier%', + testCafeEmbeddingUtils: '%testCafeEmbeddingUtils%', + testCafeDriverInstance: '%testCafeDriverInstance%' +}; diff --git a/src/configuration/option-names.js b/src/configuration/option-names.js index 3357c7e546d..6ffee558bb5 100644 --- a/src/configuration/option-names.js +++ b/src/configuration/option-names.js @@ -27,5 +27,11 @@ export default { videoPath: 'videoPath', videoOptions: 'videoOptions', videoEncodingOptions: 'videoEncodingOptions', - tsConfigPath: 'tsConfigPath' + tsConfigPath: 'tsConfigPath', + clientScripts: 'clientScripts', + requestHooks: 'requestHooks', + retryTestPages: 'retryTestPages', + hostname: 'hostname', + port1: 'port1', + port2: 'port2' }; diff --git a/src/configuration/testcafe-configuration.js b/src/configuration/testcafe-configuration.js index d653301234d..7b0eca626c4 100644 --- a/src/configuration/testcafe-configuration.js +++ b/src/configuration/testcafe-configuration.js @@ -95,6 +95,7 @@ export default class TestCafeConfiguration extends Configuration { this._prepareFilterFn(); this._ensureArrayOption(OPTION_NAMES.src); this._ensureArrayOption(OPTION_NAMES.browsers); + this._ensureArrayOption(OPTION_NAMES.clientScripts); this._prepareReporters(); } diff --git a/src/custom-client-scripts/assert-type.js b/src/custom-client-scripts/assert-type.js new file mode 100644 index 00000000000..6610e196720 --- /dev/null +++ b/src/custom-client-scripts/assert-type.js @@ -0,0 +1,5 @@ +import { assertType, is } from '../errors/runtime/type-assertions'; + +export default function (scripts) { + scripts.forEach(script => assertType([is.string, is.clientScriptInitializer], 'clientScripts', `Client script`, script)); +} diff --git a/src/custom-client-scripts/client-script.js b/src/custom-client-scripts/client-script.js new file mode 100644 index 00000000000..151fa8c6358 --- /dev/null +++ b/src/custom-client-scripts/client-script.js @@ -0,0 +1,124 @@ +import { readFile } from '../utils/promisified-functions'; +import { GeneralError } from '../errors/runtime'; +import { RUNTIME_ERRORS } from '../errors/types'; +import { isAbsolute, join } from 'path'; +import { RequestFilterRule, generateUniqueId } from 'testcafe-hammerhead'; + +const BEAUTIFY_REGEXP = /[/.:\s\\]/g; +const BEAUTIFY_CHAR = '_'; + +const EMPTY_CONTENT_STR = '{ content: }'; +const CONTENT_STR_MAX_LENGTH = 30; +const CONTENT_ELLIPSIS_STR = '...'; + +const URL_UNIQUE_PART_LENGTH = 7; + +export default class ClientScript { + constructor (init, basePath) { + this.init = init || null; + this.url = generateUniqueId(URL_UNIQUE_PART_LENGTH); + this.content = ''; + this.path = null; + this.module = null; + this.page = RequestFilterRule.ANY; + this.basePath = basePath; + } + + _resolvePath (path) { + let resolvedPath = null; + + if (isAbsolute(path)) + resolvedPath = path; + else { + if (!this.basePath) + throw new GeneralError(RUNTIME_ERRORS.clientScriptBasePathIsNotSpecified); + + resolvedPath = join(this.basePath, path); + } + + return resolvedPath; + } + + async _loadFromPath (path) { + const resolvedPath = this._resolvePath(path); + + try { + this.path = resolvedPath; + this.content = await readFile(this.path); + this.content = this.content.toString(); + this.url = path || this.url; + } + catch (e) { + throw new GeneralError(RUNTIME_ERRORS.cannotLoadClientScriptFromPath, path); + } + } + + async _loadFromModule (name) { + let resolvedPath = null; + + try { + resolvedPath = require.resolve(name); + } + catch (e) { + throw new GeneralError(RUNTIME_ERRORS.clientScriptModuleEntryPointPathCalculationError, e.message); + } + + await this._loadFromPath(resolvedPath); + + this.module = name; + } + + _prepareUrl () { + this.url = this.url.replace(BEAUTIFY_REGEXP, BEAUTIFY_CHAR).toLowerCase(); + } + + async load () { + if (this.init === null) + throw new GeneralError(RUNTIME_ERRORS.clientScriptInitializerIsNotSpecified); + else if (typeof this.init === 'string') + await this._loadFromPath(this.init); + else { + const { path: initPath, content: initContent, module: initModule, page: initPage } = this.init; + + if (initPath && initContent || initPath && initModule || initContent && initModule) + throw new GeneralError(RUNTIME_ERRORS.clientScriptInitializerMultipleContentSources); + + if (initPath) + await this._loadFromPath(initPath); + else if (initModule) + await this._loadFromModule(initModule); + else + this.content = initContent; + + if (initPage) + this.page = new RequestFilterRule(initPage); + } + + this._prepareUrl(); + } + + _contentToString () { + let displayContent = ''; + + if (this.content.length <= CONTENT_STR_MAX_LENGTH - CONTENT_ELLIPSIS_STR.length) + displayContent = this.content; + else + displayContent = this.content.substring(0, CONTENT_STR_MAX_LENGTH - CONTENT_ELLIPSIS_STR.length) + CONTENT_ELLIPSIS_STR; + + return `{ content: '${displayContent}' }`; + } + + toString () { + if (!this.content) + return EMPTY_CONTENT_STR; + + else if (this.content && !this.path) + return this._contentToString(); + + return `{ path: '${this.path}' }`; + } + + static get URL_UNIQUE_PART_LENGTH () { + return URL_UNIQUE_PART_LENGTH; + } +} diff --git a/src/custom-client-scripts/get-code.js b/src/custom-client-scripts/get-code.js new file mode 100644 index 00000000000..542bb711347 --- /dev/null +++ b/src/custom-client-scripts/get-code.js @@ -0,0 +1,11 @@ +import { processScript } from 'testcafe-hammerhead'; +import INTERNAL_PROPERTIES from '../client/driver/internal-properties'; + +export default function getCustomClientScriptCode (script) { + return `try { + ${processScript(script.content)} + } + catch (e) { + window['${INTERNAL_PROPERTIES.testCafeDriverInstance}'].onCustomClientScriptError(e, '${script.module || ''}'); + }`; +} diff --git a/src/custom-client-scripts/get-url.js b/src/custom-client-scripts/get-url.js new file mode 100644 index 00000000000..819cb99ebff --- /dev/null +++ b/src/custom-client-scripts/get-url.js @@ -0,0 +1,3 @@ +export default function (script) { + return `/custom-client-scripts/${script.url}`; +} diff --git a/src/custom-client-scripts/load.js b/src/custom-client-scripts/load.js new file mode 100644 index 00000000000..fd3116a0c7d --- /dev/null +++ b/src/custom-client-scripts/load.js @@ -0,0 +1,12 @@ +import Promise from 'pinkie'; +import ClientScript from './client-script'; + +export default async function (scriptInits, basePath) { + basePath = basePath || process.cwd(); + + const scripts = scriptInits.map(scriptInit => new ClientScript(scriptInit, basePath)); + + await Promise.all(scripts.map(script => script.load())); + + return scripts; +} diff --git a/src/custom-client-scripts/routing.js b/src/custom-client-scripts/routing.js new file mode 100644 index 00000000000..993c9c4d022 --- /dev/null +++ b/src/custom-client-scripts/routing.js @@ -0,0 +1,31 @@ +import getCustomClientScriptUrl from './get-url'; +import getCustomClientScriptCode from './get-code'; +import CONTENT_TYPES from '../assets/content-types'; + +export function register (proxy, tests) { + const routes = []; + + tests.forEach(test => { + if (test.isLegacy) + return; + + test.clientScripts.forEach(script => { + const route = getCustomClientScriptUrl(script); + + proxy.GET(route, { + content: getCustomClientScriptCode(script), + contentType: CONTENT_TYPES.javascript + }); + + routes.push(route); + }); + }); + + return routes; +} + +export function unRegister (proxy, routes) { + routes.forEach(route => { + proxy.unRegisterRoute(route, 'GET'); + }); +} diff --git a/src/custom-client-scripts/utils.js b/src/custom-client-scripts/utils.js new file mode 100644 index 00000000000..3902275006e --- /dev/null +++ b/src/custom-client-scripts/utils.js @@ -0,0 +1,34 @@ +import { chain } from 'lodash'; +import { generateUniqueId } from 'testcafe-hammerhead'; +import ClientScript from './client-script'; + +function getDuplicatedScripts (collection, predicate) { + return chain(collection) + .groupBy(predicate) + .pickBy(g => g.length > 1) + .values() + .map(value => { + return value[0]; + }) + .value(); +} + +export function setUniqueUrls (collection) { + const scriptsWithDuplicatedUrls = getDuplicatedScripts(collection, i => i.url); + + for (let i = 0; i < scriptsWithDuplicatedUrls.length; i++) + scriptsWithDuplicatedUrls[i].url = scriptsWithDuplicatedUrls[i].url + '-' + generateUniqueId(ClientScript.URL_UNIQUE_PART_LENGTH); + + return collection; +} + +export function findProblematicScripts (collection) { + const nonEmptyScripts = collection.filter(s => !!s.content); + const scriptsWithDuplicatedContent = getDuplicatedScripts(nonEmptyScripts, s => s.content); + const emptyScripts = collection.filter(s => !s.content); + + return { + duplicatedContent: scriptsWithDuplicatedContent, + empty: emptyScripts + }; +} diff --git a/src/errors/runtime/templates.js b/src/errors/runtime/templates.js index a25cf8198d5..f129ba56a27 100644 --- a/src/errors/runtime/templates.js +++ b/src/errors/runtime/templates.js @@ -73,5 +73,10 @@ export default { '* 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.', - [RUNTIME_ERRORS.cannotFindTypescriptConfigurationFile]: 'Unable to find the TypeScript configuration file in "{filePath}"', + [RUNTIME_ERRORS.cannotFindTypescriptConfigurationFile]: 'Unable to find the TypeScript configuration file in "{filePath}"', + [RUNTIME_ERRORS.clientScriptInitializerIsNotSpecified]: 'Specify the JavaScript file path, module name or script content to inject a client script.', + [RUNTIME_ERRORS.clientScriptBasePathIsNotSpecified]: 'Specify the base path for the client script file.', + [RUNTIME_ERRORS.clientScriptInitializerMultipleContentSources]: 'You cannot combine the file path, module name and script content when you specify a client script to inject.', + [RUNTIME_ERRORS.cannotLoadClientScriptFromPath]: 'Cannot load a client script from {path}', + [RUNTIME_ERRORS.clientScriptModuleEntryPointPathCalculationError]: 'An error occurred when trying to locate the injected client script module:\n\n{errorMessage}.' }; diff --git a/src/errors/runtime/type-assertions.js b/src/errors/runtime/type-assertions.js index a7fa6508626..65b972924ff 100644 --- a/src/errors/runtime/type-assertions.js +++ b/src/errors/runtime/type-assertions.js @@ -84,6 +84,11 @@ export const is = { requestHookSubclass: { name: 'RequestHook subclass', predicate: value => value instanceof RequestHook && value.constructor && value.constructor !== RequestHook + }, + + clientScriptInitializer: { + name: 'client script initializer', + predicate: value => typeof value === 'object' && ['path', 'content', 'module'].some(prop => value && prop in value) } }; diff --git a/src/errors/test-run/index.js b/src/errors/test-run/index.js index 8580a8964f5..c73de56f2bb 100644 --- a/src/errors/test-run/index.js +++ b/src/errors/test-run/index.js @@ -163,6 +163,23 @@ export class UncaughtExceptionError extends TestRunErrorBase { } } +export class UncaughtErrorInCustomClientScriptCode extends TestRunErrorBase { + constructor (err) { + super(TEST_RUN_ERRORS.uncaughtErrorInCustomClientScriptCode); + + this.errMsg = String(err); + } +} + +export class UncaughtErrorInCustomClientScriptLoadedFromModule extends TestRunErrorBase { + constructor (err, moduleName) { + super(TEST_RUN_ERRORS.uncaughtErrorInCustomClientScriptCodeLoadedFromModule); + + this.errMsg = String(err); + this.moduleName = moduleName; + } +} + // Assertion errors //-------------------------------------------------------------------- diff --git a/src/errors/test-run/templates.js b/src/errors/test-run/templates.js index e9a5a69d22c..17973bb7bdb 100644 --- a/src/errors/test-run/templates.js +++ b/src/errors/test-run/templates.js @@ -292,6 +292,19 @@ export default { [TEST_RUN_ERRORS.requestHookUnhandledError]: err => markup(err, ` An unhandled error occurred in the "${err.methodName}" method of the "${err.hookClassName}" class: + ${escapeHtml(err.errMsg)} + `), + + [TEST_RUN_ERRORS.uncaughtErrorInCustomClientScriptCode]: err => markup(err, ` + An error occurred in a script injected into the tested page: + + ${escapeHtml(err.errMsg)} + `), + + [TEST_RUN_ERRORS.uncaughtErrorInCustomClientScriptCodeLoadedFromModule]: err => markup(err, ` + An error occurred in the '${err.moduleName}' module injected into the tested page. Make sure that this module can be executed in the browser environment. + + Error details: ${escapeHtml(err.errMsg)} `) }; diff --git a/src/errors/types.js b/src/errors/types.js index a9bf91f0c90..64c71ab1101 100644 --- a/src/errors/types.js +++ b/src/errors/types.js @@ -4,67 +4,69 @@ // ------------------------------------------------------------- export const TEST_RUN_ERRORS = { - uncaughtErrorOnPage: 'E1', - uncaughtErrorInTestCode: 'E2', - uncaughtNonErrorObjectInTestCode: 'E3', - uncaughtErrorInClientFunctionCode: 'E4', - uncaughtErrorInCustomDOMPropertyCode: 'E5', - unhandledPromiseRejection: 'E6', - uncaughtException: 'E7', - missingAwaitError: 'E8', - actionIntegerOptionError: 'E9', - actionPositiveIntegerOptionError: 'E10', - actionBooleanOptionError: 'E11', - actionSpeedOptionError: 'E12', - actionOptionsTypeError: 'E14', - actionBooleanArgumentError: 'E15', - actionStringArgumentError: 'E16', - actionNullableStringArgumentError: 'E17', - actionStringOrStringArrayArgumentError: 'E18', - actionStringArrayElementError: 'E19', - actionIntegerArgumentError: 'E20', - actionRoleArgumentError: 'E21', - actionPositiveIntegerArgumentError: 'E22', - actionSelectorError: 'E23', - actionElementNotFoundError: 'E24', - actionElementIsInvisibleError: 'E26', - actionSelectorMatchesWrongNodeTypeError: 'E27', - actionAdditionalElementNotFoundError: 'E28', - actionAdditionalElementIsInvisibleError: 'E29', - actionAdditionalSelectorMatchesWrongNodeTypeError: 'E30', - actionElementNonEditableError: 'E31', - actionElementNotTextAreaError: 'E32', - actionElementNonContentEditableError: 'E33', - actionElementIsNotFileInputError: 'E34', - actionRootContainerNotFoundError: 'E35', - actionIncorrectKeysError: 'E36', - actionCannotFindFileToUploadError: 'E37', - actionUnsupportedDeviceTypeError: 'E38', - actionIframeIsNotLoadedError: 'E39', - actionElementNotIframeError: 'E40', - actionInvalidScrollTargetError: 'E41', - currentIframeIsNotLoadedError: 'E42', - currentIframeNotFoundError: 'E43', - currentIframeIsInvisibleError: 'E44', - nativeDialogNotHandledError: 'E45', - uncaughtErrorInNativeDialogHandler: 'E46', - setTestSpeedArgumentError: 'E47', - setNativeDialogHandlerCodeWrongTypeError: 'E48', - clientFunctionExecutionInterruptionError: 'E49', - domNodeClientFunctionResultError: 'E50', - invalidSelectorResultError: 'E51', - cannotObtainInfoForElementSpecifiedBySelectorError: 'E52', - externalAssertionLibraryError: 'E53', - pageLoadError: 'E54', - windowDimensionsOverflowError: 'E55', - forbiddenCharactersInScreenshotPathError: 'E56', - invalidElementScreenshotDimensionsError: 'E57', - roleSwitchInRoleInitializerError: 'E58', - assertionExecutableArgumentError: 'E59', - assertionWithoutMethodCallError: 'E60', - assertionUnawaitedPromiseError: 'E61', - requestHookNotImplementedError: 'E62', - requestHookUnhandledError: 'E63' + uncaughtErrorOnPage: 'E1', + uncaughtErrorInTestCode: 'E2', + uncaughtNonErrorObjectInTestCode: 'E3', + uncaughtErrorInClientFunctionCode: 'E4', + uncaughtErrorInCustomDOMPropertyCode: 'E5', + unhandledPromiseRejection: 'E6', + uncaughtException: 'E7', + missingAwaitError: 'E8', + actionIntegerOptionError: 'E9', + actionPositiveIntegerOptionError: 'E10', + actionBooleanOptionError: 'E11', + actionSpeedOptionError: 'E12', + actionOptionsTypeError: 'E14', + actionBooleanArgumentError: 'E15', + actionStringArgumentError: 'E16', + actionNullableStringArgumentError: 'E17', + actionStringOrStringArrayArgumentError: 'E18', + actionStringArrayElementError: 'E19', + actionIntegerArgumentError: 'E20', + actionRoleArgumentError: 'E21', + actionPositiveIntegerArgumentError: 'E22', + actionSelectorError: 'E23', + actionElementNotFoundError: 'E24', + actionElementIsInvisibleError: 'E26', + actionSelectorMatchesWrongNodeTypeError: 'E27', + actionAdditionalElementNotFoundError: 'E28', + actionAdditionalElementIsInvisibleError: 'E29', + actionAdditionalSelectorMatchesWrongNodeTypeError: 'E30', + actionElementNonEditableError: 'E31', + actionElementNotTextAreaError: 'E32', + actionElementNonContentEditableError: 'E33', + actionElementIsNotFileInputError: 'E34', + actionRootContainerNotFoundError: 'E35', + actionIncorrectKeysError: 'E36', + actionCannotFindFileToUploadError: 'E37', + actionUnsupportedDeviceTypeError: 'E38', + actionIframeIsNotLoadedError: 'E39', + actionElementNotIframeError: 'E40', + actionInvalidScrollTargetError: 'E41', + currentIframeIsNotLoadedError: 'E42', + currentIframeNotFoundError: 'E43', + currentIframeIsInvisibleError: 'E44', + nativeDialogNotHandledError: 'E45', + uncaughtErrorInNativeDialogHandler: 'E46', + setTestSpeedArgumentError: 'E47', + setNativeDialogHandlerCodeWrongTypeError: 'E48', + clientFunctionExecutionInterruptionError: 'E49', + domNodeClientFunctionResultError: 'E50', + invalidSelectorResultError: 'E51', + cannotObtainInfoForElementSpecifiedBySelectorError: 'E52', + externalAssertionLibraryError: 'E53', + pageLoadError: 'E54', + windowDimensionsOverflowError: 'E55', + forbiddenCharactersInScreenshotPathError: 'E56', + invalidElementScreenshotDimensionsError: 'E57', + roleSwitchInRoleInitializerError: 'E58', + assertionExecutableArgumentError: 'E59', + assertionWithoutMethodCallError: 'E60', + assertionUnawaitedPromiseError: 'E61', + requestHookNotImplementedError: 'E62', + requestHookUnhandledError: 'E63', + uncaughtErrorInCustomClientScriptCode: 'E64', + uncaughtErrorInCustomClientScriptCodeLoadedFromModule: 'E65' }; export const RUNTIME_ERRORS = { @@ -111,5 +113,10 @@ export const RUNTIME_ERRORS = { forbiddenCharatersInScreenshotPath: 'E1040', cannotFindFFMPEG: 'E1041', compositeArgumentsError: 'E1042', - cannotFindTypescriptConfigurationFile: 'E1043' + cannotFindTypescriptConfigurationFile: 'E1043', + clientScriptInitializerIsNotSpecified: 'E1044', + clientScriptBasePathIsNotSpecified: 'E1045', + clientScriptInitializerMultipleContentSources: 'E1046', + cannotLoadClientScriptFromPath: 'E1047', + clientScriptModuleEntryPointPathCalculationError: 'E1048' }; diff --git a/src/index.js b/src/index.js index 662eb6e8c16..c4a62b26707 100644 --- a/src/index.js +++ b/src/index.js @@ -4,6 +4,7 @@ import { RUNTIME_ERRORS } from './errors/types'; import embeddingUtils from './embedding-utils'; import exportableLib from './api/exportable-lib'; import TestCafeConfiguration from './configuration/testcafe-configuration'; +import OPTION_NAMES from './configuration/option-names'; const lazyRequire = require('import-lazy')(require); const TestCafe = lazyRequire('./testcafe'); @@ -51,9 +52,9 @@ async function createTestCafe (hostname, port1, port2, sslOptions, developmentMo }); [hostname, port1, port2] = await Promise.all([ - getValidHostname(configuration.getOption('hostname')), - getValidPort(configuration.getOption('port1')), - getValidPort(configuration.getOption('port2')) + getValidHostname(configuration.getOption(OPTION_NAMES.hostname)), + getValidPort(configuration.getOption(OPTION_NAMES.port1)), + getValidPort(configuration.getOption(OPTION_NAMES.port2)) ]); configuration.mergeOptions({ hostname, port1, port2 }); diff --git a/src/notifications/warning-message.js b/src/notifications/warning-message.js index 6ceef19abfe..d798ffbb8d0 100644 --- a/src/notifications/warning-message.js +++ b/src/notifications/warning-message.js @@ -30,8 +30,10 @@ export default { '\n' + 'The placeholder{suffix} {verb} replaced with an empty string.', - cannotLoadMarketingData: 'An error has occurred while reading the marketing data. Error details:\n\n{err}', - cannotSaveMarketingData: 'An error has occurred while saving the marketing data. Error details:\n\n{err}', - cannotCalculateMarketingMessage: 'Cannot determine which promotional message to display. Attempted to display a message no. {index}' + cannotLoadMarketingData: 'An error has occurred while reading the marketing data. Error details:\n\n{err}', + cannotSaveMarketingData: 'An error has occurred while saving the marketing data. Error details:\n\n{err}', + cannotCalculateMarketingMessage: 'Cannot determine which promotional message to display. Attempted to display a message no. {index}', + clientScriptsWithEmptyContent: 'The client script you tried to inject is empty.', + clientScriptsWithDuplicatedContent: 'You injected the following client script{suffix} several times:\n {duplicatedScripts}' }; diff --git a/src/runner/bootstrapper.js b/src/runner/bootstrapper.js index 393d6144c22..a289574d6ad 100644 --- a/src/runner/bootstrapper.js +++ b/src/runner/bootstrapper.js @@ -12,19 +12,21 @@ import path from 'path'; import fs from 'fs'; import makeDir from 'make-dir'; import resolvePathRelativelyCwd from '../utils/resolve-path-relatively-cwd'; +import loadClientScripts from '../custom-client-scripts/load'; +import { setUniqueUrls } from '../custom-client-scripts/utils'; export default class Bootstrapper { constructor (browserConnectionGateway) { this.browserConnectionGateway = browserConnectionGateway; - - this.concurrency = null; - this.sources = []; - this.browsers = []; - this.reporters = []; - this.filter = null; - this.appCommand = null; - this.appInitDelay = null; - this.tsConfigPath = null; + this.concurrency = null; + this.sources = []; + this.browsers = []; + this.reporters = []; + this.filter = null; + this.appCommand = null; + this.appInitDelay = null; + this.tsConfigPath = null; + this.clientScripts = []; } static _splitBrowserInfo (browserInfo) { @@ -179,11 +181,13 @@ export default class Bootstrapper { return isLocalBrowsers.every(result => result); } - async _bootstrapSequence (browserInfo) { + async _bootstrapSequence (browserInfo, commonClientScripts) { const tests = await this._getTests(); const testedApp = await this._startTestedApp(); const browserSet = await this._getBrowserConnections(browserInfo); + await this._addCommonClientScripts(tests, commonClientScripts); + return { tests, testedApp, browserSet }; } @@ -208,7 +212,7 @@ export default class Bootstrapper { throw browserSetStatus.error; } - async _bootstrapParallel (browserInfo) { + async _bootstrapParallel (browserInfo, commonClientScripts) { let bootstrappingPromises = [ this._getBrowserConnections(browserInfo), this._getTests(), @@ -224,12 +228,28 @@ export default class Bootstrapper { const [browserSet, tests, testedApp] = bootstrappingStatuses.map(status => status.result); + await this._addCommonClientScripts(tests, commonClientScripts); + return { browserSet, tests, testedApp }; } + async _addCommonClientScripts (tests, clientScripts) { + return Promise.all(tests.map(async test => { + if (test.isLegacy) + return; + + let loadedTestClientScripts = await loadClientScripts(test.clientScripts, path.dirname(test.testFile.filename)); + + loadedTestClientScripts = clientScripts.concat(loadedTestClientScripts); + + test.clientScripts = setUniqueUrls(loadedTestClientScripts); + })); + } + // API async createRunnableConfiguration () { - const reporterPlugins = await this._getReporterPlugins(); + const reporterPlugins = await this._getReporterPlugins(); + const commonClientScripts = await loadClientScripts(this.clientScripts); // NOTE: If a user forgot to specify a browser, but has specified a path to tests, the specified path will be // considered as the browser argument, and the tests path argument will have the predefined default value. @@ -238,8 +258,8 @@ export default class Bootstrapper { const browserInfo = await this._getBrowserInfo(); if (await this._canUseParallelBootstrapping(browserInfo)) - return { reporterPlugins, ...await this._bootstrapParallel(browserInfo) }; + return { reporterPlugins, ...await this._bootstrapParallel(browserInfo, commonClientScripts) }; - return { reporterPlugins, ...await this._bootstrapSequence(browserInfo) }; + return { reporterPlugins, ...await this._bootstrapSequence(browserInfo, commonClientScripts) }; } } diff --git a/src/runner/index.js b/src/runner/index.js index 95e3141bd09..132160f0a3e 100644 --- a/src/runner/index.js +++ b/src/runner/index.js @@ -34,10 +34,12 @@ export default class Runner extends EventEmitter { // NOTE: This code is necessary only for displaying marketing messages. this.reporterPlugings = []; - this.apiMethodWasCalled = new FlagList({ - initialFlagValue: false, - flags: [OPTION_NAMES.src, OPTION_NAMES.browsers, OPTION_NAMES.reporter] - }); + this.apiMethodWasCalled = new FlagList([ + OPTION_NAMES.src, + OPTION_NAMES.browsers, + OPTION_NAMES.reporter, + OPTION_NAMES.clientScripts + ]); } _createBootstrapper (browserConnectionGateway) { @@ -58,6 +60,7 @@ export default class Runner extends EventEmitter { async _disposeTaskAndRelatedAssets (task, browserSet, reporters, testedApp) { task.abort(); + task.unRegisterClientScriptRouting(); task.clearListeners(); await this._disposeAssets(browserSet, reporters, testedApp); @@ -93,6 +96,7 @@ export default class Runner extends EventEmitter { .then(removeFromPending); this.pendingTaskPromises.push(promise); + return promise; } @@ -159,13 +163,15 @@ export default class Runner extends EventEmitter { task.on('done', stopHandlingTestErrors); - const setCompleted = () => { + const onTaskCompleted = () => { + task.unRegisterClientScriptRouting(); + completed = true; }; completionPromise - .then(setCompleted) - .catch(setCompleted); + .then(onTaskCompleted) + .catch(onTaskCompleted); const cancelTask = async () => { if (!completed) @@ -295,14 +301,15 @@ export default class Runner extends EventEmitter { this.configuration.prepare(); this.configuration.notifyAboutOverridenOptions(); - this.bootstrapper.sources = this.configuration.getOption(OPTION_NAMES.src) || this.bootstrapper.sources; - this.bootstrapper.browsers = this.configuration.getOption(OPTION_NAMES.browsers) || this.bootstrapper.browsers; - this.bootstrapper.concurrency = this.configuration.getOption(OPTION_NAMES.concurrency); - this.bootstrapper.appCommand = this.configuration.getOption(OPTION_NAMES.appCommand) || this.bootstrapper.appCommand; - this.bootstrapper.appInitDelay = this.configuration.getOption(OPTION_NAMES.appInitDelay); - this.bootstrapper.filter = this.configuration.getOption(OPTION_NAMES.filter) || this.bootstrapper.filter; - this.bootstrapper.reporters = this.configuration.getOption(OPTION_NAMES.reporter) || this.bootstrapper.reporters; - this.bootstrapper.tsConfigPath = this.configuration.getOption(OPTION_NAMES.tsConfigPath); + this.bootstrapper.sources = this.configuration.getOption(OPTION_NAMES.src) || this.bootstrapper.sources; + this.bootstrapper.browsers = this.configuration.getOption(OPTION_NAMES.browsers) || this.bootstrapper.browsers; + this.bootstrapper.concurrency = this.configuration.getOption(OPTION_NAMES.concurrency); + this.bootstrapper.appCommand = this.configuration.getOption(OPTION_NAMES.appCommand) || this.bootstrapper.appCommand; + this.bootstrapper.appInitDelay = this.configuration.getOption(OPTION_NAMES.appInitDelay); + this.bootstrapper.filter = this.configuration.getOption(OPTION_NAMES.filter) || this.bootstrapper.filter; + this.bootstrapper.reporters = this.configuration.getOption(OPTION_NAMES.reporter) || this.bootstrapper.reporters; + this.bootstrapper.tsConfigPath = this.configuration.getOption(OPTION_NAMES.tsConfigPath); + this.bootstrapper.clientScripts = this.configuration.getOption(OPTION_NAMES.clientScripts) || this.bootstrapper.clientScripts; } // API @@ -409,6 +416,19 @@ export default class Runner extends EventEmitter { return this; } + clientScripts (...scripts) { + if (this.apiMethodWasCalled.clientScripts) + throw new GeneralError(RUNTIME_ERRORS.multipleAPIMethodCallForbidden, OPTION_NAMES.clientScripts); + + scripts = this._prepareArrayParameter(scripts); + + this.configuration.mergeOptions({ [OPTION_NAMES.clientScripts]: scripts }); + + this.apiMethodWasCalled.clientScripts = true; + + return this; + } + run (options = {}) { this.apiMethodWasCalled.reset(); diff --git a/src/runner/task.js b/src/runner/task.js index b6c9691c908..d08423da365 100644 --- a/src/runner/task.js +++ b/src/runner/task.js @@ -6,6 +6,7 @@ import Screenshots from '../screenshots'; import VideoRecorder from '../video-recorder'; import WarningLog from '../notifications/warning-log'; import FixtureHookController from './fixture-hook-controller'; +import * as clientScriptsRouting from '../custom-client-scripts/routing'; export default class Task extends AsyncEventEmitter { constructor (tests, browserConnectionGroups, proxy, opts) { @@ -16,11 +17,13 @@ export default class Task extends AsyncEventEmitter { this.browserConnectionGroups = browserConnectionGroups; this.tests = tests; this.opts = opts; + this.proxy = proxy; this.screenshots = new Screenshots(this.opts.screenshotPath, this.opts.screenshotPathPattern); this.warningLog = new WarningLog(); this.fixtureHookController = new FixtureHookController(tests, browserConnectionGroups.length); this.pendingBrowserJobs = this._createBrowserJobs(proxy, this.opts); + this.clientScriptRoutes = clientScriptsRouting.register(proxy, tests); if (this.opts.videoPath) this.videoRecorders = this._createVideoRecorders(this.pendingBrowserJobs); @@ -74,6 +77,10 @@ export default class Task extends AsyncEventEmitter { return browserJobs.map(browserJob => new VideoRecorder(browserJob, this.opts.videoPath, videoOptions, this.opts.videoEncodingOptions, this.warningLog)); } + unRegisterClientScriptRouting () { + clientScriptsRouting.unRegister(this.proxy, this.clientScriptRoutes); + } + // 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 ff260798079..ed5d3dca665 100644 --- a/src/runner/test-run-controller.js +++ b/src/runner/test-run-controller.js @@ -154,6 +154,10 @@ export default class TestRunController extends AsyncEventEmitter { await this.emit('test-run-done'); } + async _emitTestRunStart () { + await this.emit('test-run-start'); + } + async _testRunBeforeDone () { let raiseEvent = !this.quarantine; @@ -195,9 +199,7 @@ export default class TestRunController extends AsyncEventEmitter { return null; } - testRun.once('start', async () => { - await this.emit('test-run-start'); - }); + testRun.once('start', async () => this._emitTestRunStart()); testRun.once('ready', async () => { if (!this.quarantine || this._isFirstQuarantineAttempt()) await this.emit('test-run-ready'); diff --git a/src/test-run/index.js b/src/test-run/index.js index f4a0b5d1d47..a99f5d93d3a 100644 --- a/src/test-run/index.js +++ b/src/test-run/index.js @@ -28,6 +28,10 @@ import WarningLog from '../notifications/warning-log'; import WARNING_MESSAGE from '../notifications/warning-message'; import { StateSnapshot, SPECIAL_ERROR_PAGE } from 'testcafe-hammerhead'; import { NavigateToCommand } from './commands/actions'; +import * as INJECTABLES from '../assets/injectables'; +import { findProblematicScripts } from '../custom-client-scripts/utils'; +import getCustomClientScriptUrl from '../custom-client-scripts/get-url'; +import { getPluralSuffix, getConcatenatedValuesString } from '../utils/string'; import { isCommandRejectableByPageError, @@ -119,15 +123,34 @@ export default class TestRun extends AsyncEventEmitter { this.quarantine = null; - this.injectable.scripts.push('/testcafe-core.js'); - this.injectable.scripts.push('/testcafe-ui.js'); - this.injectable.scripts.push('/testcafe-automation.js'); - this.injectable.scripts.push('/testcafe-driver.js'); - this.injectable.styles.push('/testcafe-ui-styles.css'); + this._addInjectables(); + this._initRequestHooks(); + } - this.requestHooks = Array.from(this.test.requestHooks); + _addClientScriptContentWarningsIfNecessary () { + const { empty, duplicatedContent } = findProblematicScripts(this.test.clientScripts); - this._initRequestHooks(); + if (empty.length) + this.warningLog.addWarning(WARNING_MESSAGE.clientScriptsWithEmptyContent); + + if (duplicatedContent.length) { + const suffix = getPluralSuffix(duplicatedContent); + const duplicatedContentClientScriptsStr = getConcatenatedValuesString(duplicatedContent, ',\n '); + + this.warningLog.addWarning(WARNING_MESSAGE.clientScriptsWithDuplicatedContent, suffix, duplicatedContentClientScriptsStr); + } + } + + _addInjectables () { + this._addClientScriptContentWarningsIfNecessary(); + this.injectable.scripts.push(...INJECTABLES.SCRIPTS); + this.injectable.userScripts.push(...this.test.clientScripts.map(script => { + return { + url: getCustomClientScriptUrl(script), + page: script.page + }; + })); + this.injectable.styles.push(INJECTABLES.TESTCAFE_UI_STYLES); } get id () { @@ -193,6 +216,8 @@ export default class TestRun extends AsyncEventEmitter { } _initRequestHooks () { + this.requestHooks = Array.from(this.test.requestHooks); + this.requestHooks.forEach(hook => this._initRequestHook(hook)); } diff --git a/src/testcafe.js b/src/testcafe.js index 71fc6df52de..a3da9418ced 100644 --- a/src/testcafe.js +++ b/src/testcafe.js @@ -1,6 +1,9 @@ import Promise from 'pinkie'; import { GeneralError } from './errors/runtime'; import { RUNTIME_ERRORS } from './errors/types'; +import CONTENT_TYPES from './assets/content-types'; +import OPTION_NAMES from './configuration/option-names'; +import * as INJECTABLES from './assets/injectables'; const lazyRequire = require('import-lazy')(require); const sourceMapSupport = lazyRequire('source-map-support'); @@ -25,7 +28,7 @@ export default class TestCafe { this.closed = false; this.proxy = new hammerhead.Proxy(hostname, port1, port2, options); - this.browserConnectionGateway = new BrowserConnectionGateway(this.proxy, { retryTestPages: configuration.getOption('retryTestPages') }); + this.browserConnectionGateway = new BrowserConnectionGateway(this.proxy, { retryTestPages: configuration.getOption(OPTION_NAMES.retryTestPages) }); this.runners = []; this.configuration = configuration; @@ -36,22 +39,22 @@ export default class TestCafe { const { favIcon, coreScript, driverScript, uiScript, uiStyle, uiSprite, automationScript, legacyRunnerScript } = loadAssets(developmentMode); - this.proxy.GET('/testcafe-core.js', { content: coreScript, contentType: 'application/x-javascript' }); - this.proxy.GET('/testcafe-driver.js', { content: driverScript, contentType: 'application/x-javascript' }); + this.proxy.GET(INJECTABLES.TESTCAFE_CORE, { content: coreScript, contentType: CONTENT_TYPES.javascript }); + this.proxy.GET(INJECTABLES.TESTCAFE_DRIVER, { content: driverScript, contentType: CONTENT_TYPES.javascript }); - this.proxy.GET('/testcafe-legacy-runner.js', { + this.proxy.GET(INJECTABLES.TESTCAFE_LEGACY_RUNNER, { content: legacyRunnerScript, - contentType: 'application/x-javascript' + contentType: CONTENT_TYPES.javascript }); - this.proxy.GET('/testcafe-automation.js', { content: automationScript, contentType: 'application/x-javascript' }); - this.proxy.GET('/testcafe-ui.js', { content: uiScript, contentType: 'application/x-javascript' }); - this.proxy.GET('/testcafe-ui-sprite.png', { content: uiSprite, contentType: 'image/png' }); - this.proxy.GET('/favicon.ico', { content: favIcon, contentType: 'image/x-icon' }); + this.proxy.GET(INJECTABLES.TESTCAFE_AUTOMATION, { content: automationScript, contentType: CONTENT_TYPES.javascript }); + this.proxy.GET(INJECTABLES.TESTCAFE_UI, { content: uiScript, contentType: CONTENT_TYPES.javascript }); + this.proxy.GET(INJECTABLES.TESTCAFE_UI_SPRITE, { content: uiSprite, contentType: CONTENT_TYPES.png }); + this.proxy.GET(INJECTABLES.TESTCAFE_ICON, { content: favIcon, contentType: CONTENT_TYPES.icon }); - this.proxy.GET('/testcafe-ui-styles.css', { + this.proxy.GET(INJECTABLES.TESTCAFE_UI_STYLES, { content: uiStyle, - contentType: 'text/css', + contentType: CONTENT_TYPES.css, isShadowUIStylesheet: true }); } diff --git a/src/utils/flag-list.js b/src/utils/flag-list.js index 986f231104e..b2d31ac424e 100644 --- a/src/utils/flag-list.js +++ b/src/utils/flag-list.js @@ -1,9 +1,9 @@ export default class FlagList { - constructor ({ initialFlagValue, flags }) { - Object.defineProperty(this, '_initialFlagValue', { writable: true, value: initialFlagValue }); + constructor (flags) { + Object.defineProperty(this, '_initialFlagValue', { writable: true, value: false }); flags.forEach(flag => { - this[flag] = initialFlagValue; + this[flag] = false; }); } diff --git a/src/utils/promisified-functions.js b/src/utils/promisified-functions.js index 05e2ecf8cc6..71e277a2000 100644 --- a/src/utils/promisified-functions.js +++ b/src/utils/promisified-functions.js @@ -5,7 +5,6 @@ import { PNG } from 'pngjs'; import promisifyEvent from 'promisify-event'; import promisify from './promisify'; - export const readDir = promisify(fs.readdir); export const stat = promisify(fs.stat); export const writeFile = promisify(fs.writeFile); diff --git a/src/utils/string.js b/src/utils/string.js index 71aed519e0c..57ab723ba50 100644 --- a/src/utils/string.js +++ b/src/utils/string.js @@ -1,5 +1,7 @@ import indentString from 'indent-string'; +const CONCATENATED_VALUES_SEPARATOR = ', '; + function rtrim (str) { return str.replace(/\s+$/, ''); } @@ -78,11 +80,12 @@ export function getPluralSuffix (array) { return array.length > 1 ? 's' : ''; } -export function getConcatenatedValuesString (array) { - return array.map(item => `"${item}"`).join(', '); +export function getConcatenatedValuesString (array, separator) { + separator = separator || CONCATENATED_VALUES_SEPARATOR; + + return array.map(item => `"${item}"`).join(separator); } export function getToBeInPastTense (array) { return array.length > 1 ? 'were' : 'was'; } - diff --git a/test/functional/fixtures/api/es-next/custom-client-scripts/data/console_log_1.js b/test/functional/fixtures/api/es-next/custom-client-scripts/data/console_log_1.js new file mode 100644 index 00000000000..296d5492b00 --- /dev/null +++ b/test/functional/fixtures/api/es-next/custom-client-scripts/data/console_log_1.js @@ -0,0 +1 @@ +console.log(1); diff --git a/test/functional/fixtures/api/es-next/custom-client-scripts/data/empty.js b/test/functional/fixtures/api/es-next/custom-client-scripts/data/empty.js new file mode 100644 index 00000000000..e69de29bb2d diff --git a/test/functional/fixtures/api/es-next/custom-client-scripts/data/page-url.js b/test/functional/fixtures/api/es-next/custom-client-scripts/data/page-url.js new file mode 100644 index 00000000000..db1e5a795d4 --- /dev/null +++ b/test/functional/fixtures/api/es-next/custom-client-scripts/data/page-url.js @@ -0,0 +1 @@ +window.pageUrl = window.location.toString(); diff --git a/test/functional/fixtures/api/es-next/custom-client-scripts/data/set-flag1.js b/test/functional/fixtures/api/es-next/custom-client-scripts/data/set-flag1.js new file mode 100644 index 00000000000..1cfe5fd9957 --- /dev/null +++ b/test/functional/fixtures/api/es-next/custom-client-scripts/data/set-flag1.js @@ -0,0 +1 @@ +window.flag1 = true; diff --git a/test/functional/fixtures/api/es-next/custom-client-scripts/data/with-error.js b/test/functional/fixtures/api/es-next/custom-client-scripts/data/with-error.js new file mode 100644 index 00000000000..0734e03bd52 --- /dev/null +++ b/test/functional/fixtures/api/es-next/custom-client-scripts/data/with-error.js @@ -0,0 +1,3 @@ +var t123456; + +t123456['some-property']; diff --git a/test/functional/fixtures/api/es-next/custom-client-scripts/helpers/index.js b/test/functional/fixtures/api/es-next/custom-client-scripts/helpers/index.js new file mode 100644 index 00000000000..1e72ceba307 --- /dev/null +++ b/test/functional/fixtures/api/es-next/custom-client-scripts/helpers/index.js @@ -0,0 +1,8 @@ +import { ClientFunction } from 'testcafe'; + +const getFlag = ClientFunction(flagName => !!window[flagName]); + +const getFlag1 = getFlag.bind(null, 'flag1'); +const getFlag2 = getFlag.bind(null, 'flag2'); + +export { getFlag1, getFlag2 }; diff --git a/test/functional/fixtures/api/es-next/custom-client-scripts/pages/index.html b/test/functional/fixtures/api/es-next/custom-client-scripts/pages/index.html new file mode 100644 index 00000000000..6d64d1fa31c --- /dev/null +++ b/test/functional/fixtures/api/es-next/custom-client-scripts/pages/index.html @@ -0,0 +1,10 @@ + + + + + Title + + +

Page for testing custom client scripts

+ + diff --git a/test/functional/fixtures/api/es-next/custom-client-scripts/test.js b/test/functional/fixtures/api/es-next/custom-client-scripts/test.js new file mode 100644 index 00000000000..fbec4069918 --- /dev/null +++ b/test/functional/fixtures/api/es-next/custom-client-scripts/test.js @@ -0,0 +1,82 @@ +const path = require('path'); +const { expect } = require('chai'); + +describe('Custom client scripts', () => { + it('Runner', () => { + return runTests('./testcafe-fixtures/runner.js', null, { + clientScripts: 'test/functional/fixtures/api/es-next/custom-client-scripts/data/set-flag1.js' + }); + }); + + it('Custom client script should be proxied', () => { + return runTests('./testcafe-fixtures/check-proxing.js', null, { + clientScripts: 'test/functional/fixtures/api/es-next/custom-client-scripts/data/page-url.js' + }); + }); + + it('Test API', () => { + return runTests('./testcafe-fixtures/test-api.js'); + }); + + it('Mixed', () => { + return runTests('./testcafe-fixtures/mixed.js', null, { + clientScripts: 'test/functional/fixtures/api/es-next/custom-client-scripts/data/set-flag1.js' + }); + }); + + it('Specified page', () => { + return runTests('./testcafe-fixtures/specified-page.js', null, { + clientScripts: { + path: 'test/functional/fixtures/api/es-next/custom-client-scripts/data/set-flag1.js', + page: 'http://localhost:3000/fixtures/api/es-next/custom-client-scripts/pages/index.html' + } + }); + }); + + it('Warnings', () => { + return runTests('./testcafe-fixtures/warnings.js') + .then(() => { + expect(testReport.warnings).eql([ + 'The client script you tried to inject is empty.', + 'You injected the following client scripts several times:\n' + + ' "{ content: \'1\' }",\n' + + ` "{ path: '${path.resolve('test/functional/fixtures/api/es-next/custom-client-scripts/data/set-flag1.js')}' }"` + ]); + }); + }); + + it('Test file and an injected script are located in the same folder', () => { + return runTests('./testcafe-fixtures/folder/index.js'); + }); + + it('Execution order', () => { + return runTests('./testcafe-fixtures/execution-order.js', null, { + clientScripts: 'test/functional/fixtures/api/es-next/custom-client-scripts/data/console_log_1.js' + }); + }); + + describe('Should handle errors of the injected scripts', () => { + // NOTE: Error message format is a little different in various browsers. + // This is why, we run these tests only in Chrome + it('Script loaded from file', () => { + return runTests('./testcafe-fixtures/error-in-script-from-file.js', null, { shouldFail: true, only: 'chrome' }) + .catch(errs => { + expect(errs[0]).eql("An error occurred in a script injected into the tested page: TypeError: Cannot read property 'some-property' of undefined [[user-agent]]"); + }); + }); + + it('Script loaded from module', () => { + return runTests('./testcafe-fixtures/error-in-script-from-module.js', null, { shouldFail: true, only: 'chrome' }) + .catch(errs => { + expect(errs[0]).eql("An error occurred in the 'is-docker' module injected into the tested page. Make sure that this module can be executed in the browser environment. Error details: ReferenceError: require is not defined [[user-agent]]"); + }); + }); + + it('Wrong module name', () => { + return runTests('./testcafe-fixtures/wrong-module-name.js', null, { shouldFail: true }) + .catch(err => { + expect(err.message).eql("An error occurred when trying to locate the injected client script module:\n\nCannot find module 'wrong-module-name'."); + }); + }); + }); +}); diff --git a/test/functional/fixtures/api/es-next/custom-client-scripts/testcafe-fixtures/check-proxing.js b/test/functional/fixtures/api/es-next/custom-client-scripts/testcafe-fixtures/check-proxing.js new file mode 100644 index 00000000000..bd69439c779 --- /dev/null +++ b/test/functional/fixtures/api/es-next/custom-client-scripts/testcafe-fixtures/check-proxing.js @@ -0,0 +1,10 @@ +import { ClientFunction } from 'testcafe'; + +const getPageUrl = ClientFunction(() => window.pageUrl); + +fixture `Fixture` + .page('http://localhost:3000/fixtures/api/es-next/custom-client-scripts/pages/index.html'); + +test('test', async t => { + await t.expect(getPageUrl()).eql('http://localhost:3000/fixtures/api/es-next/custom-client-scripts/pages/index.html'); +}); diff --git a/test/functional/fixtures/api/es-next/custom-client-scripts/testcafe-fixtures/error-in-script-from-file.js b/test/functional/fixtures/api/es-next/custom-client-scripts/testcafe-fixtures/error-in-script-from-file.js new file mode 100644 index 00000000000..fb826f87ce9 --- /dev/null +++ b/test/functional/fixtures/api/es-next/custom-client-scripts/testcafe-fixtures/error-in-script-from-file.js @@ -0,0 +1,4 @@ +fixture `Fixture` + .clientScripts('../data/with-error.js'); + +test('test', async () => {}); diff --git a/test/functional/fixtures/api/es-next/custom-client-scripts/testcafe-fixtures/error-in-script-from-module.js b/test/functional/fixtures/api/es-next/custom-client-scripts/testcafe-fixtures/error-in-script-from-module.js new file mode 100644 index 00000000000..24bdfe25ce2 --- /dev/null +++ b/test/functional/fixtures/api/es-next/custom-client-scripts/testcafe-fixtures/error-in-script-from-module.js @@ -0,0 +1,4 @@ +fixture `Fixture` + .clientScripts({ module: 'is-docker' }); + +test('test', async () => {}); diff --git a/test/functional/fixtures/api/es-next/custom-client-scripts/testcafe-fixtures/execution-order.js b/test/functional/fixtures/api/es-next/custom-client-scripts/testcafe-fixtures/execution-order.js new file mode 100644 index 00000000000..d8621b062bb --- /dev/null +++ b/test/functional/fixtures/api/es-next/custom-client-scripts/testcafe-fixtures/execution-order.js @@ -0,0 +1,10 @@ +fixture `Fixture` + .clientScripts({ content: 'console.log(2);' }); + +test + .clientScripts({ content: 'console.log(3);' }) + ('test', async t => { + const { log } = await t.getBrowserConsoleMessages(); + + await t.expect(log.toString()).eql('1,2,3'); + }); diff --git a/test/functional/fixtures/api/es-next/custom-client-scripts/testcafe-fixtures/folder/index.js b/test/functional/fixtures/api/es-next/custom-client-scripts/testcafe-fixtures/folder/index.js new file mode 100644 index 00000000000..eccc0a8b02f --- /dev/null +++ b/test/functional/fixtures/api/es-next/custom-client-scripts/testcafe-fixtures/folder/index.js @@ -0,0 +1,8 @@ +import { getFlag1 } from '../../helpers'; + +fixture `Fixture` + .clientScripts('script.js'); + +test('test', async t => { + await t.expect(getFlag1()).ok(); +}); diff --git a/test/functional/fixtures/api/es-next/custom-client-scripts/testcafe-fixtures/folder/script.js b/test/functional/fixtures/api/es-next/custom-client-scripts/testcafe-fixtures/folder/script.js new file mode 100644 index 00000000000..1cfe5fd9957 --- /dev/null +++ b/test/functional/fixtures/api/es-next/custom-client-scripts/testcafe-fixtures/folder/script.js @@ -0,0 +1 @@ +window.flag1 = true; diff --git a/test/functional/fixtures/api/es-next/custom-client-scripts/testcafe-fixtures/mixed.js b/test/functional/fixtures/api/es-next/custom-client-scripts/testcafe-fixtures/mixed.js new file mode 100644 index 00000000000..dc632014559 --- /dev/null +++ b/test/functional/fixtures/api/es-next/custom-client-scripts/testcafe-fixtures/mixed.js @@ -0,0 +1,11 @@ +import { getFlag1, getFlag2 } from '../helpers'; + +fixture `Fixture`; + +test + .clientScripts({ content: 'window.flag2 = true; ' }) + ('test', async t => { + await t + .expect(getFlag1()).ok() + .expect(getFlag2()).ok(); + }); diff --git a/test/functional/fixtures/api/es-next/custom-client-scripts/testcafe-fixtures/runner.js b/test/functional/fixtures/api/es-next/custom-client-scripts/testcafe-fixtures/runner.js new file mode 100644 index 00000000000..41012be16fd --- /dev/null +++ b/test/functional/fixtures/api/es-next/custom-client-scripts/testcafe-fixtures/runner.js @@ -0,0 +1,7 @@ +import { getFlag1 } from '../helpers'; + +fixture `Fixture`; + +test('test', async t => { + await t.expect(getFlag1()).ok(); +}); diff --git a/test/functional/fixtures/api/es-next/custom-client-scripts/testcafe-fixtures/specified-page.js b/test/functional/fixtures/api/es-next/custom-client-scripts/testcafe-fixtures/specified-page.js new file mode 100644 index 00000000000..d5c9d3fb63e --- /dev/null +++ b/test/functional/fixtures/api/es-next/custom-client-scripts/testcafe-fixtures/specified-page.js @@ -0,0 +1,10 @@ +import { getFlag1 } from '../helpers'; + +fixture `Fixture`; + +test('test', async t => { + await t + .expect(getFlag1()).notOk() + .navigateTo('http://localhost:3000/fixtures/api/es-next/custom-client-scripts/pages/index.html') + .expect(getFlag1()).ok(); +}); diff --git a/test/functional/fixtures/api/es-next/custom-client-scripts/testcafe-fixtures/test-api.js b/test/functional/fixtures/api/es-next/custom-client-scripts/testcafe-fixtures/test-api.js new file mode 100644 index 00000000000..eeb36887628 --- /dev/null +++ b/test/functional/fixtures/api/es-next/custom-client-scripts/testcafe-fixtures/test-api.js @@ -0,0 +1,12 @@ +import { getFlag1, getFlag2 } from '../helpers'; + +fixture `Fixture` + .clientScripts('../data/set-flag1.js'); + +test + .clientScripts({ content: 'window.flag2 = true; ' }) + ('test', async t => { + await t + .expect(getFlag1()).ok() + .expect(getFlag2()).ok(); + }); diff --git a/test/functional/fixtures/api/es-next/custom-client-scripts/testcafe-fixtures/warnings.js b/test/functional/fixtures/api/es-next/custom-client-scripts/testcafe-fixtures/warnings.js new file mode 100644 index 00000000000..497cf1ccf88 --- /dev/null +++ b/test/functional/fixtures/api/es-next/custom-client-scripts/testcafe-fixtures/warnings.js @@ -0,0 +1,11 @@ +fixture `Fixture`; + +test + .clientScripts( + { content: '' }, + { content: '' }, + { content: '1' }, + { content: '1' }, + { path: '../data/set-flag1.js' }, + { path: '../data/set-flag1.js' }) + ('test', async () => {}); diff --git a/test/functional/fixtures/api/es-next/custom-client-scripts/testcafe-fixtures/wrong-module-name.js b/test/functional/fixtures/api/es-next/custom-client-scripts/testcafe-fixtures/wrong-module-name.js new file mode 100644 index 00000000000..c92ab445247 --- /dev/null +++ b/test/functional/fixtures/api/es-next/custom-client-scripts/testcafe-fixtures/wrong-module-name.js @@ -0,0 +1,4 @@ +fixture `Fixture` + .clientScripts({ module: 'wrong-module-name' }); + +test('test', async () => {}); diff --git a/test/functional/fixtures/api/es-next/request-hooks/test.js b/test/functional/fixtures/api/es-next/request-hooks/test.js index c7a1d4d95f5..f40b62dd1db 100644 --- a/test/functional/fixtures/api/es-next/request-hooks/test.js +++ b/test/functional/fixtures/api/es-next/request-hooks/test.js @@ -50,5 +50,9 @@ describe('Request Hooks', () => { expect(testReport.errs[4]).contains('An unhandled error occurred in the "onResponse" method of the "Hook3" class:\n\nError: Unhandled error.'); }); }); + + it('Execution order', () => { + return runTests('./testcafe-fixtures/api/execution-order.js', null, { only: 'chrome' }); + }); }); }); diff --git a/test/functional/fixtures/api/es-next/request-hooks/testcafe-fixtures/api/add-remove-request-hook.js b/test/functional/fixtures/api/es-next/request-hooks/testcafe-fixtures/api/add-remove-request-hook.js index 94c2b52478f..1a29c737222 100644 --- a/test/functional/fixtures/api/es-next/request-hooks/testcafe-fixtures/api/add-remove-request-hook.js +++ b/test/functional/fixtures/api/es-next/request-hooks/testcafe-fixtures/api/add-remove-request-hook.js @@ -2,8 +2,8 @@ import { RequestHook } from 'testcafe'; import path from 'path'; import config from '../../../../../../config'; -const ResultPromise = require(path.resolve('./lib/utils/re-executable-promise')); -const pageUrl = 'http://localhost:3000/fixtures/api/es-next/request-hooks/pages/index.html'; +const ReExecutablePromise = require(path.resolve('./lib/utils/re-executable-promise')); +const pageUrl = 'http://localhost:3000/fixtures/api/es-next/request-hooks/pages/index.html'; class TestRequestHook extends RequestHook { constructor () { @@ -13,7 +13,7 @@ class TestRequestHook extends RequestHook { } get onResponseCallCount () { - return ResultPromise.fromFn(async () => this.onResponseCallCountInternal); + return ReExecutablePromise.fromFn(async () => this.onResponseCallCountInternal); } async onRequest () {} diff --git a/test/functional/fixtures/api/es-next/request-hooks/testcafe-fixtures/api/execution-order.js b/test/functional/fixtures/api/es-next/request-hooks/testcafe-fixtures/api/execution-order.js new file mode 100644 index 00000000000..65d0625ff56 --- /dev/null +++ b/test/functional/fixtures/api/es-next/request-hooks/testcafe-fixtures/api/execution-order.js @@ -0,0 +1,48 @@ +import { RequestHook } from 'testcafe'; +import path from 'path'; + +const ReExecutablePromise = require(path.resolve('./lib/utils/re-executable-promise')); +const result = []; +const pageUrl = 'http://localhost:3000/fixtures/api/es-next/request-hooks/pages/index.html'; + +class TestRequestHook extends RequestHook { + constructor (name) { + super(pageUrl); + + this.name = name; + this._done = false; + } + + async onRequest () { + result.push(this.name); + + this._done = true; + } + + async onResponse () {} + + get done () { + return ReExecutablePromise.fromFn(async () => this._done); + } +} + +const hook1 = new TestRequestHook('1'); +const hook2 = new TestRequestHook('2'); + +fixture `Fixture` + .requestHooks(hook1); + +test + .requestHooks(hook2) + ('test', async t => { + await t + .navigateTo(pageUrl) + .expect(hook1.done).ok() + .expect(hook2.done).ok(); + + // NOTE: If caching is prevented for the page and 'retryTestPages' option is turned on + // then the tested page will be requested twice. + const expectedResult = t.testRun.opts.retryTestPages ? '1,2,1,2' : '1,2'; + + await t.expect(result.toString()).eql(expectedResult); + }); diff --git a/test/functional/setup.js b/test/functional/setup.js index bef12493c82..e1004d7e0d0 100644 --- a/test/functional/setup.js +++ b/test/functional/setup.js @@ -173,33 +173,37 @@ before(function () { global.testReport = null; global.testCafe = testCafe; - global.runTests = (fixture, testName, opts) => { + global.runTests = (fixture, testName, opts = {}) => { const stream = createSimpleTestStream(); const runner = testCafe.createRunner(); const fixturePath = typeof fixture !== 'string' || path.isAbsolute(fixture) ? fixture : path.join(path.dirname(caller()), fixture); - const skipJsErrors = opts && opts.skipJsErrors; - const disablePageReloads = opts && opts.disablePageReloads; - const quarantineMode = opts && opts.quarantineMode; const selectorTimeout = opts && opts.selectorTimeout || FUNCTIONAL_TESTS_SELECTOR_TIMEOUT; const assertionTimeout = opts && opts.assertionTimeout || FUNCTIONAL_TESTS_ASSERTION_TIMEOUT; 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 ? 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; - const proxy = opts && opts.useProxy; - const proxyBypass = opts && opts.proxyBypass; - const customReporters = opts && opts.reporter; - const skipUncaughtErrors = opts && opts.skipUncaughtErrors; - const stopOnFirstFail = opts && opts.stopOnFirstFail; + const clientScripts = opts && opts.clientScripts || []; + + const { + skipJsErrors, + disablePageReloads, + quarantineMode, + screenshotPathPattern, + screenshotsOnFails, + videoOptions, + videoEncodingOptions, + speed, + appCommand, + appInitDelay, + proxyBypass, + skipUncaughtErrors, + reporter: customReporters, + useProxy: proxy, + only: onlyOption, + skip: skipOption, + stopOnFirstFail + } = opts; const actualBrowsers = browsersInfo.filter(browserInfo => { const { alias, userAgent } = browserInfo.settings; @@ -245,6 +249,7 @@ before(function () { .screenshots(screenshotPath, screenshotsOnFails, screenshotPathPattern) .video(videoPath, videoOptions, videoEncodingOptions) .startApp(appCommand, appInitDelay) + .clientScripts(clientScripts) .run({ skipJsErrors, disablePageReloads, diff --git a/test/server/api-test.js b/test/server/api-test.js index 68fd2c30e90..190ef25ad44 100644 --- a/test/server/api-test.js +++ b/test/server/api-test.js @@ -263,6 +263,33 @@ describe('API', function () { }); }); + it('Should raise an error if "fixture.requestHooks" method calls several times', () => { + const testfile = resolve('test/server/data/test-suites/request-hooks/fixture-request-hooks-call-several-times.js'); + + return compile(testfile) + .then(() => { + throw new Error('Promise rejection expected'); + }) + .catch(err => { + assertAPIError(err, { + stackTop: testfile, + + message: 'Cannot prepare tests due to an error.\n\n' + + 'You cannot call the "requestHooks" method more than once. Pass an array of parameters to this method instead.', + + callsite: ' 3 |const logger1 = new RequestLogger();\n' + + ' 4 |const logger2 = new RequestLogger();\n' + + ' 5 |\n' + + ' 6 |fixture `Fixture`\n' + + ' 7 | .requestHooks(logger1)\n' + + ' > 8 | .requestHooks(logger2);\n' + + ' 9 |\n' + + ' 10 |test(\'test\', async t => {});\n' + + ' 11 |' + }); + }); + }); + it('Should collect meta data', function () { return compile('test/server/data/test-suites/meta/testfile.js') .then(function (compiled) { @@ -298,6 +325,52 @@ describe('API', function () { }); }); }); + + it('Should raise an error if "fixture.clientScripts" method takes a wrong argument', () => { + const testfile = resolve('test/server/data/test-suites/custom-client-scripts/fixture-client-scripts-has-wrong-type.js'); + + return compile(testfile) + .then(() => { + throw new Error('Promise rejection expected'); + }) + .catch(err => { + assertAPIError(err, { + stackTop: testfile, + + message: 'Cannot prepare tests due to an error.\n\n' + + 'Client script is expected to be a string or a client script initializer, but it was number.', + + callsite: ' > 1 |fixture.clientScripts(8);\n' + + ' 2 |\n' + + ' 3 |test(\'test\', async t => {});\n' + + ' 4 |' + }); + }); + }); + + it('Should raise an error if "fixture.clientScripts" method calls several times', () => { + const testfile = resolve('test/server/data/test-suites/custom-client-scripts/fixture-client-scripts-call-several-times.js'); + + return compile(testfile) + .then(() => { + throw new Error('Promise rejection expected'); + }) + .catch(err => { + assertAPIError(err, { + stackTop: testfile, + + message: 'Cannot prepare tests due to an error.\n\n' + + 'You cannot call the "clientScripts" method more than once. Pass an array of parameters to this method instead.', + + callsite: ' 1 |fixture `Fixture`\n' + + ' 2 | .clientScripts(\'script1.js\')\n' + + ' > 3 | .clientScripts(\'script2.js\');\n' + + ' 4 |\n' + + ' 5 |test(\'test\', async t => {});\n' + + ' 6 |' + }); + }); + }); }); describe('test', function () { @@ -447,6 +520,32 @@ describe('API', function () { }); }); + it('Should raise an error if "test.requestHooks" method calls several times', () => { + const testfile = resolve('test/server/data/test-suites/request-hooks/test-request-hooks-call-several-times.js'); + + return compile(testfile) + .then(() => { + throw new Error('Promise rejection expected'); + }) + .catch(err => { + assertAPIError(err, { + stackTop: testfile, + + message: 'Cannot prepare tests due to an error.\n\n' + + 'You cannot call the "requestHooks" method more than once. Pass an array of parameters to this method instead.', + + callsite: ' 5 |\n' + + ' 6 |fixture `Fixture`;\n' + + ' 7 |\n' + + ' 8 |test\n' + + ' 9 | .requestHooks(logger1)\n' + + ' > 10 | .requestHooks(logger2)\n' + + ' 11 | (\'test\', async t => {});\n' + + ' 12 |' + }); + }); + }); + it('Should collect meta data', function () { return compile('test/server/data/test-suites/meta/testfile.js') .then(function (compiled) { @@ -505,6 +604,54 @@ describe('API', function () { }); }); }); + + it('Should raise an error if "test.clientScripts" method takes a wrong argument', () => { + const testfile = resolve('test/server/data/test-suites/custom-client-scripts/test-client-scripts-has-wrong-type.js'); + + return compile(testfile) + .then(() => { + throw new Error('Promise rejection expected'); + }) + .catch(err => { + assertAPIError(err, { + stackTop: testfile, + + message: 'Cannot prepare tests due to an error.\n\n' + + 'Client script is expected to be a string or a client script initializer, but it was number.', + + callsite: ' 1 |fixture `Fixture`;\n' + + ' 2 |\n' + + ' 3 |test\n' + + ' > 4 | .clientScripts(8)\n' + + ' 5 | (\'test\', async t => {});' + }); + }); + }); + + it('Should raise an error if "test.clientScripts" method calls several times', () => { + const testfile = resolve('test/server/data/test-suites/custom-client-scripts/test-client-scripts-call-several-times.js'); + + return compile(testfile) + .then(() => { + throw new Error('Promise rejection expected'); + }) + .catch(err => { + assertAPIError(err, { + stackTop: testfile, + + message: 'Cannot prepare tests due to an error.\n\n' + + 'You cannot call the "clientScripts" method more than once. Pass an array of parameters to this method instead.', + + callsite: ' 1 |fixture `Fixture`;\n' + + ' 2 |\n' + + ' 3 |test\n' + + ' 4 | .clientScripts(\'script1.js\')\n' + + ' > 5 | .clientScripts(\'script2.js\')\n' + + ' 6 | (\'test\', async t => {});\n' + + ' 7 |' + }); + }); + }); }); describe('Selector', function () { diff --git a/test/server/cli-argument-parser-test.js b/test/server/cli-argument-parser-test.js index 2ba0feb78e0..0a181637892 100644 --- a/test/server/cli-argument-parser-test.js +++ b/test/server/cli-argument-parser-test.js @@ -495,6 +495,15 @@ describe('CLI argument parser', function () { }); }); + it('Client scripts', () => { + return parse('--client-scripts asserts/jquery.js,mockDate.js') + .then(parser => { + expect(parser.opts.clientScripts).eql([ + 'asserts/jquery.js', + 'mockDate.js' + ]); + }); + }); it('Should parse reporters and their output file paths and ensure they exist', function () { const cwd = process.cwd(); @@ -574,7 +583,8 @@ describe('CLI argument parser', function () { { long: '--video' }, { long: '--video-options' }, { long: '--video-encoding-options' }, - { long: '--ts-config-path' } + { long: '--ts-config-path' }, + { long: '--client-scripts', short: '--cs' } ]; const parser = new CliArgumentParser(''); diff --git a/test/server/configuration-test.js b/test/server/configuration-test.js index 4c5079bca5a..be333f2abcd 100644 --- a/test/server/configuration-test.js +++ b/test/server/configuration-test.js @@ -52,7 +52,8 @@ describe('TestCafeConfiguration', () => { 'fixtureGrep': 'fixture\\d', 'testMeta': { test: 'meta' }, 'fixtureMeta': { fixture: 'meta' } - } + }, + 'clientScripts': 'test-client-script.js' }); }); @@ -97,6 +98,7 @@ describe('TestCafeConfiguration', () => { expect(configuration.getOption('filter').fixtureGrep.test('fixture1')).to.be.true; expect(configuration.getOption('filter').testMeta).to.be.deep.equal({ test: 'meta' }); expect(configuration.getOption('filter').fixtureMeta).to.be.deep.equal({ fixture: 'meta' }); + expect(configuration.getOption('clientScripts')).eql([ 'test-client-script.js' ]); }); }); diff --git a/test/server/custom-client-scripts-test.js b/test/server/custom-client-scripts-test.js new file mode 100644 index 00000000000..6864f280938 --- /dev/null +++ b/test/server/custom-client-scripts-test.js @@ -0,0 +1,259 @@ +const { expect } = require('chai'); +const ClientScript = require('../../lib/custom-client-scripts/client-script'); +const loadClientScripts = require('../../lib/custom-client-scripts/load'); +const getCustomClientScriptURL = require('../../lib/custom-client-scripts/get-url'); +const { RequestFilterRule } = require('testcafe-hammerhead'); +const tmp = require('tmp'); +const fs = require('fs'); +const { setUniqueUrls, findProblematicScripts } = require('../../lib/custom-client-scripts/utils'); +const { is } = require('../../lib/errors/runtime/type-assertions'); + +describe('Client scripts', () => { + tmp.setGracefulCleanup(); + + const testScriptContent = 'var i = 3;'; + const testModuleName = 'is-docker'; + const testBasePath = process.cwd(); + + function createScriptFile (content) { + const file = tmp.fileSync(); + + fs.writeFileSync(file.name, content || testScriptContent); + + return file; + } + + it('Should throw the error if script initializer is not specified', () => { + return new ClientScript().load() + .then(() => { + expect.fail('Should throw the error'); + }) + .catch(e => { + expect(e.message).eql('Specify the JavaScript file path, module name or script content to inject a client script.'); + }); + }); + + it('Should throw the error if scripts`s base path is not specified and path is relative', () => { + return new ClientScript({ path: './relative-path' }) + .load() + .then(() => { + expect.fail('Should throw the error'); + }) + .catch(e => { + expect(e.message).eql('Specify the base path for the client script file.'); + }); + }); + + describe('Content', () => { + it('Empty content', () => { + const script = new ClientScript({ content: '' }); + + return script + .load() + .then(() => { + expect(script.content).eql(''); + expect(script.toString()).eql('{ content: }'); + }); + }); + + it('Short content', () => { + const script = new ClientScript({ content: testScriptContent }); + + return script + .load() + .then(() => { + expect(script.content).eql(testScriptContent); + expect(script.url).is.not.empty; + expect(script.path).eql(null); + expect(script.toString()).eql("{ content: 'var i = 3;' }"); + }); + }); + + it('Long content', () => { + const script = new ClientScript({ content: testScriptContent.repeat(10) }); + + return script + .load() + .then(() => { + expect(script.toString()).eql("{ content: 'var i = 3;var i = 3;var i =...' }"); + }); + }); + }); + + describe('Load', () => { + it('From full path', () => { + const file = createScriptFile(); + const script = new ClientScript({ path: file.name }); + + return script + .load() + .then(() => { + expect(script.content).eql(testScriptContent); + expect(script.path).eql(file.name); + expect(script.url).contains('_'); + expect(script.url).not.contains('/'); + expect(script.url).not.contains('\\'); + expect(script.toString()).eql(`{ path: '${file.name}' }`); + }); + }); + + it('Node module', () => { + const script = new ClientScript({ module: testModuleName }); + + return script.load() + .then(() => { + expect(script.content).contains('return hasDockerEnv() || hasDockerCGroup()'); + expect(script.module).eql(testModuleName); + }); + }); + + it('From relative path', () => { + const script = new ClientScript('test/server/data/custom-client-scripts/utils.js', testBasePath); + + return script.load() + .then(() => { + expect(script.content).contains('function test () {'); + }); + }); + }); + + describe('"Page" property', () => { + it('Default value', () => { + const script = new ClientScript({ content: testScriptContent }); + + return script.load() + .then(() => { + expect(script.page).eql(RequestFilterRule.ANY); + }); + }); + + it('Initializer', () => { + const script = new ClientScript( { + page: 'http://example.com', + content: testScriptContent + }); + + return script.load() + .then(() => { + expect(script.page).to.be.an.instanceOf(RequestFilterRule); + }); + }); + }); + + describe('Should throw the error if "content", "path" and "module" properties were combined', () => { + it('"path" and "content"', () => { + const file = createScriptFile(); + const script = new ClientScript({ path: file.name, content: testScriptContent }); + + return script.load() + .then(() => { + expect.fail('Should throw the error'); + }).catch(e => { + expect(e.message).eql('You cannot combine the file path, module name and script content when you specify a client script to inject.'); + }); + }); + + it('"path" and "module"', () => { + const file = createScriptFile(); + const script = new ClientScript({ path: file.name, module: testModuleName }); + + return script.load() + .then(() => { + expect.fail('Should throw the error'); + }).catch(e => { + expect(e.message).eql('You cannot combine the file path, module name and script content when you specify a client script to inject.'); + }); + }); + + it('"content" and "module"', () => { + const script = new ClientScript({ module: testModuleName, content: testScriptContent }); + + return script.load() + .then(() => { + expect.fail('Should throw the error'); + }).catch(e => { + expect(e.message).eql('You cannot combine the file path, module name and script content when you specify a client script to inject.'); + }); + }); + }); + + it('Should handle IO errors', () => { + return new ClientScript({ path: '/non-existing-file' }, testBasePath) + .load() + .then(() => { + expect.fail('Should throw the error'); + }) + .catch(e => { + expect(e.message).eql('Cannot load a client script from /non-existing-file'); + }); + }); + + describe('Prepare', () => { + it('Should correct non-unique urls', () => { + const scripts = [ + { module: testModuleName }, + { module: testModuleName } + ]; + + return loadClientScripts(scripts, testBasePath) + .then(setUniqueUrls) + .then(result => { + expect(result.length).eql(2); + expect(result[0].url).to.not.eql(result[1].url); + }); + }); + + describe('Should provide information about problematic scripts', () => { + it('Duplicated content', () => { + const scripts = [ + { content: '1' }, + { content: '1' }, + { content: '2' }, + { content: '2' }, + { content: '3' }, + ]; + + return loadClientScripts(scripts) + .then(findProblematicScripts) + .then(({ duplicatedContent }) => { + expect(duplicatedContent.length).eql(2); + expect(duplicatedContent[0].content).eql(scripts[0].content); + expect(duplicatedContent[1].content).eql(scripts[2].content); + }); + }); + + it('Empty content', () => { + const scripts = [ + { content: '' }, + { content: '123' }, + { content: '' } + ]; + + return loadClientScripts(scripts) + .then(findProblematicScripts) + .then(({ empty }) => { + expect(empty[0].content).is.empty; + expect(empty[1].content).is.empty; + }); + }); + }); + }); + + it('Type assertion', () => { + expect(is.clientScriptInitializer.predicate({})).to.be.false; + expect(is.clientScriptInitializer.predicate(null)).to.be.false; + expect(is.clientScriptInitializer.predicate({ path: '/path' })).to.be.true; + expect(is.clientScriptInitializer.predicate({ content: 'var i = 0' })).to.be.true; + expect(is.clientScriptInitializer.predicate({ module: 'module-name' })).to.be.true; + }); + + it('Get URL', () => { + const script = new ClientScript({ content: testScriptContent }); + + return script + .load() + .then(() => { + expect(getCustomClientScriptURL(script)).eql('/custom-client-scripts/' + script.url); + }); + }); +}); diff --git a/test/server/data/custom-client-scripts/utils.js b/test/server/data/custom-client-scripts/utils.js new file mode 100644 index 00000000000..b82094495e2 --- /dev/null +++ b/test/server/data/custom-client-scripts/utils.js @@ -0,0 +1,3 @@ +function test () { + +} diff --git a/test/server/data/expected-test-run-errors/uncaughtErrorInCustomClientScriptCode b/test/server/data/expected-test-run-errors/uncaughtErrorInCustomClientScriptCode new file mode 100644 index 00000000000..41bd5d5f212 --- /dev/null +++ b/test/server/data/expected-test-run-errors/uncaughtErrorInCustomClientScriptCode @@ -0,0 +1,21 @@ +An error occurred in a script injected into the tested page: + +TypeError: Cannot read property "prop" of undefined + +Browser: Chrome 15.0.874 / Mac OS X 10.8.1 +Screenshot: /unix/path/with/ + + 18 |function func1 () { + 19 | record = createCallsiteRecord({ byFunctionName: 'func1' }); + 20 |} + 21 | + 22 |(function func2 () { + > 23 | func1(); + 24 |})(); + 25 | + 26 |stackTrace.filter.deattach(stackFilter); + 27 | + 28 |module.exports = record; + + at func2 (testfile.js:23:5) + at Object. (testfile.js:24:3) diff --git a/test/server/data/expected-test-run-errors/uncaughtErrorInCustomClientScriptCodeLoadedFromModule b/test/server/data/expected-test-run-errors/uncaughtErrorInCustomClientScriptCodeLoadedFromModule new file mode 100644 index 00000000000..a397557f98c --- /dev/null +++ b/test/server/data/expected-test-run-errors/uncaughtErrorInCustomClientScriptCodeLoadedFromModule @@ -0,0 +1,23 @@ +An error occurred in the 'test-module' module injected into the tested page. +Make sure that this module can be executed in the browser environment. + +Error details: +TypeError: Cannot read property "prop" of undefined + +Browser: Chrome 15.0.874 / Mac OS X 10.8.1 +Screenshot: /unix/path/with/ + + 18 |function func1 () { + 19 | record = createCallsiteRecord({ byFunctionName: 'func1' }); + 20 |} + 21 | + 22 |(function func2 () { + > 23 | func1(); + 24 |})(); + 25 | + 26 |stackTrace.filter.deattach(stackFilter); + 27 | + 28 |module.exports = record; + + at func2 (testfile.js:23:5) + at Object. (testfile.js:24:3) diff --git a/test/server/data/test-suites/custom-client-scripts/fixture-client-scripts-call-several-times.js b/test/server/data/test-suites/custom-client-scripts/fixture-client-scripts-call-several-times.js new file mode 100644 index 00000000000..d58cf2a917b --- /dev/null +++ b/test/server/data/test-suites/custom-client-scripts/fixture-client-scripts-call-several-times.js @@ -0,0 +1,5 @@ +fixture `Fixture` + .clientScripts('script1.js') + .clientScripts('script2.js'); + +test('test', async t => {}); diff --git a/test/server/data/test-suites/custom-client-scripts/fixture-client-scripts-has-wrong-type.js b/test/server/data/test-suites/custom-client-scripts/fixture-client-scripts-has-wrong-type.js new file mode 100644 index 00000000000..11214c23d3a --- /dev/null +++ b/test/server/data/test-suites/custom-client-scripts/fixture-client-scripts-has-wrong-type.js @@ -0,0 +1,3 @@ +fixture.clientScripts(8); + +test('test', async t => {}); diff --git a/test/server/data/test-suites/custom-client-scripts/test-client-scripts-call-several-times.js b/test/server/data/test-suites/custom-client-scripts/test-client-scripts-call-several-times.js new file mode 100644 index 00000000000..4891ea47900 --- /dev/null +++ b/test/server/data/test-suites/custom-client-scripts/test-client-scripts-call-several-times.js @@ -0,0 +1,6 @@ +fixture `Fixture`; + +test + .clientScripts('script1.js') + .clientScripts('script2.js') + ('test', async t => {}); diff --git a/test/server/data/test-suites/custom-client-scripts/test-client-scripts-has-wrong-type.js b/test/server/data/test-suites/custom-client-scripts/test-client-scripts-has-wrong-type.js new file mode 100644 index 00000000000..a26aa44928a --- /dev/null +++ b/test/server/data/test-suites/custom-client-scripts/test-client-scripts-has-wrong-type.js @@ -0,0 +1,5 @@ +fixture `Fixture`; + +test + .clientScripts(8) + ('test', async t => {}); diff --git a/test/server/data/test-suites/request-hooks/fixture-request-hooks-call-several-times.js b/test/server/data/test-suites/request-hooks/fixture-request-hooks-call-several-times.js new file mode 100644 index 00000000000..e9e3b212e04 --- /dev/null +++ b/test/server/data/test-suites/request-hooks/fixture-request-hooks-call-several-times.js @@ -0,0 +1,10 @@ +import { RequestLogger } from 'testcafe'; + +const logger1 = new RequestLogger(); +const logger2 = new RequestLogger(); + +fixture `Fixture` + .requestHooks(logger1) + .requestHooks(logger2); + +test('test', async t => {}); diff --git a/test/server/data/test-suites/request-hooks/test-request-hooks-call-several-times.js b/test/server/data/test-suites/request-hooks/test-request-hooks-call-several-times.js new file mode 100644 index 00000000000..b17eb3e08c4 --- /dev/null +++ b/test/server/data/test-suites/request-hooks/test-request-hooks-call-several-times.js @@ -0,0 +1,11 @@ +import { RequestLogger } from 'testcafe'; + +const logger1 = new RequestLogger(); +const logger2 = new RequestLogger(); + +fixture `Fixture`; + +test + .requestHooks(logger1) + .requestHooks(logger2) + ('test', async t => {}); diff --git a/test/server/error-handle-test.js b/test/server/error-handle-test.js index a93a4e22e1a..eeb769af7e5 100644 --- a/test/server/error-handle-test.js +++ b/test/server/error-handle-test.js @@ -11,6 +11,7 @@ const AsyncEventEmitter = require('../../lib/utils/async-event-emitter'); const delay = require('../../lib/utils/delay'); class TaskMock extends AsyncEventEmitter { + unRegisterClientScriptRouting () {} } class BrowserSetMock extends EventEmitter { diff --git a/test/server/runner-test.js b/test/server/runner-test.js index 7d4be4b9e96..355ecb6935f 100644 --- a/test/server/runner-test.js +++ b/test/server/runner-test.js @@ -747,6 +747,21 @@ describe('Runner', () => { }); }); + describe('.clientScripts', () => { + it('Should raise an error for the multiple ".clientScripts" method call', () => { + try { + runner + .clientScripts({ source: 'var i = 0;' }) + .clientScripts({ source: 'var i = 1;' }); + + throw new Error('Should raise an appropriate error.'); + } + catch (err) { + expect(err.message).startsWith('You cannot call the "clientScripts" method more than once. Pass an array of parameters to this method instead.'); + } + }); + }); + describe('Regression', () => { it('Should not have unhandled rejections in runner (GH-825)', () => { let rejectionReason = null; diff --git a/test/server/test-run-error-formatting-test.js b/test/server/test-run-error-formatting-test.js index 3d1159cf940..880e488a043 100644 --- a/test/server/test-run-error-formatting-test.js +++ b/test/server/test-run-error-formatting-test.js @@ -68,7 +68,9 @@ const { RoleSwitchInRoleInitializerError, ActionRoleArgumentError, RequestHookNotImplementedMethodError, - RequestHookUnhandledError + RequestHookUnhandledError, + UncaughtErrorInCustomClientScriptCode, + UncaughtErrorInCustomClientScriptLoadedFromModule } = require('../../lib/errors/test-run'); const { createSimpleTestStream } = require('../functional/utils/stream'); @@ -374,6 +376,14 @@ describe('Error formatting', () => { it('Should format "requestHookUnhandledError"', () => { assertErrorMessage('request-hook-unhandled-error', new RequestHookUnhandledError(new Error('Test error'), 'MyHook', 'onRequest')); }); + + it('Should format "uncaughtErrorInCustomClientScriptCode"', () => { + assertErrorMessage('uncaughtErrorInCustomClientScriptCode', new UncaughtErrorInCustomClientScriptCode(new TypeError('Cannot read property "prop" of undefined'))); + }); + + it('Should format "uncaughtErrorInCustomClientScriptCodeLoadedFromModule"', () => { + assertErrorMessage('uncaughtErrorInCustomClientScriptCodeLoadedFromModule', new UncaughtErrorInCustomClientScriptLoadedFromModule(new TypeError('Cannot read property "prop" of undefined'), 'test-module')); + }); }); describe('Test coverage', () => { diff --git a/test/server/test-run-request-handler-test.js b/test/server/test-run-request-handler-test.js index 82e171a0fd4..dfafc96fa47 100644 --- a/test/server/test-run-request-handler-test.js +++ b/test/server/test-run-request-handler-test.js @@ -4,7 +4,13 @@ const delay = require('../../lib/utils/delay'); describe('Request handling', () => { it('Should abort request if it\'s longer than 3s', () => { - const testRun = new TestRun({ fixture: { path: '' }, requestHooks: {} }, {}, null, null, {}); + const testMock = { + fixture: { path: '' }, + clientScripts: [], + requestHooks: [] + }; + + const testRun = new TestRun(testMock, {}, null, null, {}); const handleRequestPromise = testRun.ready({ status: { id: 1, consoleMessages: [] } }); diff --git a/test/server/util-test.js b/test/server/util-test.js index 04092693946..653047a0a35 100644 --- a/test/server/util-test.js +++ b/test/server/util-test.js @@ -220,6 +220,7 @@ describe('Utils', () => { it('Get concatenated values string', () => { expect(getConcatenatedValuesString(['param_1'])).eql('"param_1"'); expect(getConcatenatedValuesString(['param_1', 'param_2', 'param_3'])).eql('"param_1", "param_2", "param_3"'); + expect(getConcatenatedValuesString(['1', '2'], ' ')).eql('"1" "2"'); }); describe('Moment Module Loader', () => { diff --git a/ts-defs/index.d.ts b/ts-defs/index.d.ts index 97d352fcf4a..589202049c4 100644 --- a/ts-defs/index.d.ts +++ b/ts-defs/index.d.ts @@ -1549,12 +1549,19 @@ interface Assertion { } +// Custom Client Scripts +interface ClientScript { + content?: string; + path?: string; + page?: any; +} + interface TestCafe { /** * Creates the test runner that is used to configure and launch test tasks. */ createRunner(): Runner; - + /** * Creates the live mode test runner that is used to configure and launch test tasks. */ @@ -1667,6 +1674,13 @@ interface Runner { */ useProxy(host: string, bypassRules?: string | string[]): this; + /** + * Injects scripts into pages visited during the test execution. + * + * @param scripts - Scripts that should be added to the tested pages. + */ + clientScripts (scripts: ClientScript | ClientScript[]): this; + /** * Runs tests according to the current configuration. Returns the number of failed tests. */