diff --git a/src/api/test-controller/add-message.ts b/src/api/test-controller/add-message.ts new file mode 100644 index 00000000000..c66b127560b --- /dev/null +++ b/src/api/test-controller/add-message.ts @@ -0,0 +1,17 @@ +import addRenderedWarning from '../../notifications/add-rendered-warning'; +import TestRun from '../../test-run'; +import TestCafeErrorList from '../../errors/error-list'; + +export function addWarnings (callsiteSet: Set>, message: string, testRun: TestRun): void { + callsiteSet.forEach(callsite => { + addRenderedWarning(testRun.warningLog, message, callsite); + callsiteSet.delete(callsite); + }); +} + +export function addErrors (callsiteSet: Set>, ErrorClass: any, errList: TestCafeErrorList): void { + callsiteSet.forEach(callsite => { + errList.addError(new ErrorClass(callsite)); + callsiteSet.delete(callsite); + }); +} diff --git a/src/api/test-controller/assertion.ts b/src/api/test-controller/assertion.ts index 39b1c869387..4ea676b42d5 100644 --- a/src/api/test-controller/assertion.ts +++ b/src/api/test-controller/assertion.ts @@ -60,7 +60,7 @@ export default class Assertion { message = void 0; } - return this._testController._enqueueCommand(command, { + return this._testController.enqueueCommand(command, { assertionType: command.methodName, actual: this._actual, expected: assertionArgs.expected, diff --git a/src/api/test-controller/custom-actions.ts b/src/api/test-controller/custom-actions.ts new file mode 100644 index 00000000000..2defcf10236 --- /dev/null +++ b/src/api/test-controller/custom-actions.ts @@ -0,0 +1,47 @@ +import { getCallsiteForMethod } from '../../errors/get-callsite'; +import { RunCustomActionCommand } from '../../test-run/commands/actions'; +import { delegateAPI } from '../../utils/delegated-api'; +import { Dictionary } from '../../configuration/interfaces'; +import TestController from './index'; +import delegatedAPI from './delegated-api'; + +export default class CustomActions { + private _testController: TestController; + private readonly _customActions: Dictionary; + + constructor (testController: TestController, customActions: Dictionary) { + this._testController = testController; + this._customActions = customActions || {}; + + this._registerCustomActions(); + } + + _registerCustomActions (): void { + Object.entries(this._customActions).forEach(([ name, fn ]) => { + // @ts-ignore + this[delegatedAPI(name)] = (...args) => { + const callsite = getCallsiteForMethod(name) || void 0; + + return this._testController.enqueueCommand(RunCustomActionCommand, { fn, args, name }, this._validateCommand, callsite); + }; + }); + + this._delegateAPI(this._customActions); + } + + _validateCommand (): boolean { + return true; + } + + _delegateAPI (actions: Dictionary): void { + const customActionsList = Object.entries(actions).map(([name]) => { + return { + srcProp: delegatedAPI(name), + apiProp: name, + accessor: '', + }; + }); + + delegateAPI(this, customActionsList, { useCurrentCtxAsHandler: true }); + } +} diff --git a/src/api/test-controller/delegated-api.ts b/src/api/test-controller/delegated-api.ts new file mode 100644 index 00000000000..d537891d26e --- /dev/null +++ b/src/api/test-controller/delegated-api.ts @@ -0,0 +1,3 @@ +export default function delegatedAPI (methodName: string, accessor = ''): string { + return `_${ methodName }$${ accessor }`; +} diff --git a/src/api/test-controller/index.d.ts b/src/api/test-controller/index.d.ts index fb3067f8118..886014efed0 100644 --- a/src/api/test-controller/index.d.ts +++ b/src/api/test-controller/index.d.ts @@ -9,7 +9,7 @@ export default class TestController { public constructor (testRun: TestRun | TestRunProxy); public testRun: TestRun; public warningLog: WarningLog; - public _enqueueCommand (CmdCtor: unknown, cmdArgs: object, validateCommand: Function): () => Promise; + public enqueueCommand (CmdCtor: unknown, cmdArgs: object, validateCommand: Function, callsite?: CallsiteRecord): () => Promise; public checkForExcessiveAwaits (checkedCallsite: CallsiteRecord, { actionId }: CommandBase): void; public static enableDebugForNonDebugCommands (): void; public static disableDebugForNonDebugCommands (): void; diff --git a/src/api/test-controller/index.js b/src/api/test-controller/index.js index 6ed644d95de..feab4a7bdc0 100644 --- a/src/api/test-controller/index.js +++ b/src/api/test-controller/index.js @@ -83,15 +83,13 @@ import ReExecutablePromise from '../../utils/re-executable-promise'; import sendRequest from '../../test-run/request/send'; import { RequestRuntimeError } from '../../errors/runtime'; import { RUNTIME_ERRORS } from '../../errors/types'; +import CustomActions from './custom-actions'; +import delegatedAPI from './delegated-api'; const originalThen = Promise.resolve().then; let inDebug = false; -function delegatedAPI (methodName, accessor = '') { - return `_${methodName}$${accessor}`; -} - export default class TestController { constructor (testRun) { this._executionContext = null; @@ -99,6 +97,7 @@ export default class TestController { this.testRun = testRun; this.executionChain = Promise.resolve(); this.warningLog = testRun.warningLog; + this._customActions = new CustomActions(this, testRun?.opts?.customActions); this._addTestControllerToExecutionChain(); } @@ -164,8 +163,9 @@ export default class TestController { return this.executionChain; } - _enqueueCommand (CmdCtor, cmdArgs, validateCommandFn) { - const callsite = getCallsiteForMethod(CmdCtor.methodName); + enqueueCommand (CmdCtor, cmdArgs, validateCommandFn, callsite) { + callsite = callsite || getCallsiteForMethod(CmdCtor.methodName); + const command = this._createCommand(CmdCtor, cmdArgs, callsite); if (typeof validateCommandFn === 'function') @@ -222,8 +222,12 @@ export default class TestController { return this.testRun.browser; } + _customActions$getter () { + return this._customActions || new CustomActions(this, this.testRun.opts.customActions); + } + [delegatedAPI(DispatchEventCommand.methodName)] (selector, eventName, options = {}) { - return this._enqueueCommand(DispatchEventCommand, { selector, eventName, options, relatedTarget: options.relatedTarget }); + return this.enqueueCommand(DispatchEventCommand, { selector, eventName, options, relatedTarget: options.relatedTarget }); } _prepareCookieArguments (args, isSetCommand = false) { @@ -246,17 +250,17 @@ export default class TestController { } [delegatedAPI(GetCookiesCommand.methodName)] (...args) { - return this._enqueueCommand(GetCookiesCommand, this._prepareCookieArguments(args)); + return this.enqueueCommand(GetCookiesCommand, this._prepareCookieArguments(args)); } [delegatedAPI(SetCookiesCommand.methodName)] (...args) { const { urls, cookies } = this._prepareCookieArguments(args, true); - return this._enqueueCommand(SetCookiesCommand, { cookies, url: urls[0] }); + return this.enqueueCommand(SetCookiesCommand, { cookies, url: urls[0] }); } [delegatedAPI(DeleteCookiesCommand.methodName)] (...args) { - return this._enqueueCommand(DeleteCookiesCommand, this._prepareCookieArguments(args)); + return this.enqueueCommand(DeleteCookiesCommand, this._prepareCookieArguments(args)); } _prepareRequestArguments (bindOptions, ...args) { @@ -319,27 +323,27 @@ export default class TestController { } [delegatedAPI(ClickCommand.methodName)] (selector, options) { - return this._enqueueCommand(ClickCommand, { selector, options }); + return this.enqueueCommand(ClickCommand, { selector, options }); } [delegatedAPI(RightClickCommand.methodName)] (selector, options) { - return this._enqueueCommand(RightClickCommand, { selector, options }); + return this.enqueueCommand(RightClickCommand, { selector, options }); } [delegatedAPI(DoubleClickCommand.methodName)] (selector, options) { - return this._enqueueCommand(DoubleClickCommand, { selector, options }); + return this.enqueueCommand(DoubleClickCommand, { selector, options }); } [delegatedAPI(HoverCommand.methodName)] (selector, options) { - return this._enqueueCommand(HoverCommand, { selector, options }); + return this.enqueueCommand(HoverCommand, { selector, options }); } [delegatedAPI(DragCommand.methodName)] (selector, dragOffsetX, dragOffsetY, options) { - return this._enqueueCommand(DragCommand, { selector, dragOffsetX, dragOffsetY, options }); + return this.enqueueCommand(DragCommand, { selector, dragOffsetX, dragOffsetY, options }); } [delegatedAPI(DragToElementCommand.methodName)] (selector, destinationSelector, options) { - return this._enqueueCommand(DragToElementCommand, { selector, destinationSelector, options }); + return this.enqueueCommand(DragToElementCommand, { selector, destinationSelector, options }); } _getSelectorForScroll (args) { @@ -380,7 +384,7 @@ export default class TestController { if (typeof args[0] === 'number') [ x, y, options ] = args; - return this._enqueueCommand(ScrollCommand, { selector, x, y, position, options }); + return this.enqueueCommand(ScrollCommand, { selector, x, y, position, options }); } [delegatedAPI(ScrollByCommand.methodName)] (...args) { @@ -388,23 +392,23 @@ export default class TestController { const [byX, byY, options] = args; - return this._enqueueCommand(ScrollByCommand, { selector, byX, byY, options }); + return this.enqueueCommand(ScrollByCommand, { selector, byX, byY, options }); } [delegatedAPI(ScrollIntoViewCommand.methodName)] (selector, options) { - return this._enqueueCommand(ScrollIntoViewCommand, { selector, options }); + return this.enqueueCommand(ScrollIntoViewCommand, { selector, options }); } [delegatedAPI(TypeTextCommand.methodName)] (selector, text, options) { - return this._enqueueCommand(TypeTextCommand, { selector, text, options }); + return this.enqueueCommand(TypeTextCommand, { selector, text, options }); } [delegatedAPI(SelectTextCommand.methodName)] (selector, startPos, endPos, options) { - return this._enqueueCommand(SelectTextCommand, { selector, startPos, endPos, options }); + return this.enqueueCommand(SelectTextCommand, { selector, startPos, endPos, options }); } [delegatedAPI(SelectTextAreaContentCommand.methodName)] (selector, startLine, startPos, endLine, endPos, options) { - return this._enqueueCommand(SelectTextAreaContentCommand, { + return this.enqueueCommand(SelectTextAreaContentCommand, { selector, startLine, startPos, @@ -415,7 +419,7 @@ export default class TestController { } [delegatedAPI(SelectEditableContentCommand.methodName)] (startSelector, endSelector, options) { - return this._enqueueCommand(SelectEditableContentCommand, { + return this.enqueueCommand(SelectEditableContentCommand, { startSelector, endSelector, options, @@ -423,30 +427,30 @@ export default class TestController { } [delegatedAPI(PressKeyCommand.methodName)] (keys, options) { - return this._enqueueCommand(PressKeyCommand, { keys, options }); + return this.enqueueCommand(PressKeyCommand, { keys, options }); } [delegatedAPI(WaitCommand.methodName)] (timeout) { - return this._enqueueCommand(WaitCommand, { timeout }); + return this.enqueueCommand(WaitCommand, { timeout }); } [delegatedAPI(NavigateToCommand.methodName)] (url) { - return this._enqueueCommand(NavigateToCommand, { url }); + return this.enqueueCommand(NavigateToCommand, { url }); } [delegatedAPI(SetFilesToUploadCommand.methodName)] (selector, filePath) { - return this._enqueueCommand(SetFilesToUploadCommand, { selector, filePath }); + return this.enqueueCommand(SetFilesToUploadCommand, { selector, filePath }); } [delegatedAPI(ClearUploadCommand.methodName)] (selector) { - return this._enqueueCommand(ClearUploadCommand, { selector }); + return this.enqueueCommand(ClearUploadCommand, { selector }); } [delegatedAPI(TakeScreenshotCommand.methodName)] (options) { if (options && typeof options !== 'object') options = { path: options }; - return this._enqueueCommand(TakeScreenshotCommand, options); + return this.enqueueCommand(TakeScreenshotCommand, options); } [delegatedAPI(TakeElementScreenshotCommand.methodName)] (selector, ...args) { @@ -461,33 +465,33 @@ export default class TestController { else commandArgs.path = args[0]; - return this._enqueueCommand(TakeElementScreenshotCommand, commandArgs); + return this.enqueueCommand(TakeElementScreenshotCommand, commandArgs); } [delegatedAPI(ResizeWindowCommand.methodName)] (width, height) { - return this._enqueueCommand(ResizeWindowCommand, { width, height }); + return this.enqueueCommand(ResizeWindowCommand, { width, height }); } [delegatedAPI(ResizeWindowToFitDeviceCommand.methodName)] (device, options) { - return this._enqueueCommand(ResizeWindowToFitDeviceCommand, { device, options }); + return this.enqueueCommand(ResizeWindowToFitDeviceCommand, { device, options }); } [delegatedAPI(MaximizeWindowCommand.methodName)] () { - return this._enqueueCommand(MaximizeWindowCommand); + return this.enqueueCommand(MaximizeWindowCommand); } [delegatedAPI(SwitchToIframeCommand.methodName)] (selector) { - return this._enqueueCommand(SwitchToIframeCommand, { selector }); + return this.enqueueCommand(SwitchToIframeCommand, { selector }); } [delegatedAPI(SwitchToMainWindowCommand.methodName)] () { - return this._enqueueCommand(SwitchToMainWindowCommand); + return this.enqueueCommand(SwitchToMainWindowCommand); } [delegatedAPI(OpenWindowCommand.methodName)] (url) { this._validateMultipleWindowCommand(OpenWindowCommand.methodName); - return this._enqueueCommand(OpenWindowCommand, { url }); + return this.enqueueCommand(OpenWindowCommand, { url }); } [delegatedAPI(CloseWindowCommand.methodName)] (window) { @@ -495,13 +499,13 @@ export default class TestController { this._validateMultipleWindowCommand(CloseWindowCommand.methodName); - return this._enqueueCommand(CloseWindowCommand, { windowId }); + return this.enqueueCommand(CloseWindowCommand, { windowId }); } [delegatedAPI(GetCurrentWindowCommand.methodName)] () { this._validateMultipleWindowCommand(GetCurrentWindowCommand.methodName); - return this._enqueueCommand(GetCurrentWindowCommand); + return this.enqueueCommand(GetCurrentWindowCommand); } [delegatedAPI(SwitchToWindowCommand.methodName)] (windowSelector) { @@ -521,19 +525,19 @@ export default class TestController { args = { windowId: windowSelector?.id }; } - return this._enqueueCommand(command, args); + return this.enqueueCommand(command, args); } [delegatedAPI(SwitchToParentWindowCommand.methodName)] () { this._validateMultipleWindowCommand(SwitchToParentWindowCommand.methodName); - return this._enqueueCommand(SwitchToParentWindowCommand); + return this.enqueueCommand(SwitchToParentWindowCommand); } [delegatedAPI(SwitchToPreviousWindowCommand.methodName)] () { this._validateMultipleWindowCommand(SwitchToPreviousWindowCommand.methodName); - return this._enqueueCommand(SwitchToPreviousWindowCommand); + return this.enqueueCommand(SwitchToPreviousWindowCommand); } _eval$ (fn, options) { @@ -547,7 +551,7 @@ export default class TestController { } [delegatedAPI(SetNativeDialogHandlerCommand.methodName)] (fn, options) { - return this._enqueueCommand(SetNativeDialogHandlerCommand, { + return this.enqueueCommand(SetNativeDialogHandlerCommand, { dialogHandler: { fn, options }, }); } @@ -592,37 +596,37 @@ export default class TestController { // NOTE: do not need to enqueue the Debug command if we are in debugging mode. // The Debug command will be executed by CDP. // Also, we are forced to add empty function to the execution chain to preserve it. - return this.isCompilerServiceMode() ? this._enqueueTask(DebugCommand.methodName, noop) : this._enqueueCommand(DebugCommand); + return this.isCompilerServiceMode() ? this._enqueueTask(DebugCommand.methodName, noop) : this.enqueueCommand(DebugCommand); } [delegatedAPI(SetTestSpeedCommand.methodName)] (speed) { - return this._enqueueCommand(SetTestSpeedCommand, { speed }); + return this.enqueueCommand(SetTestSpeedCommand, { speed }); } [delegatedAPI(SetPageLoadTimeoutCommand.methodName)] (duration) { - return this._enqueueCommand(SetPageLoadTimeoutCommand, { duration }, (testController, command) => { + return this.enqueueCommand(SetPageLoadTimeoutCommand, { duration }, (testController, command) => { addWarning(testController.warningLog, { message: getDeprecationMessage(DEPRECATED.setPageLoadTimeout), actionId: command.actionId }); }); } [delegatedAPI(UseRoleCommand.methodName)] (role) { - return this._enqueueCommand(UseRoleCommand, { role }); + return this.enqueueCommand(UseRoleCommand, { role }); } [delegatedAPI(SkipJsErrorsCommand.methodName)] (options) { - return this._enqueueCommand(SkipJsErrorsCommand, { options }); + return this.enqueueCommand(SkipJsErrorsCommand, { options }); } [delegatedAPI(AddRequestHooksCommand.methodName)] (...hooks) { hooks = flattenDeep(hooks); - return this._enqueueCommand(AddRequestHooksCommand, { hooks }); + return this.enqueueCommand(AddRequestHooksCommand, { hooks }); } [delegatedAPI(RemoveRequestHooksCommand.methodName)] (...hooks) { hooks = flattenDeep(hooks); - return this._enqueueCommand(RemoveRequestHooksCommand, { hooks }); + return this.enqueueCommand(RemoveRequestHooksCommand, { hooks }); } static enableDebugForNonDebugCommands () { diff --git a/src/api/test-run-tracker.ts b/src/api/test-run-tracker.ts index 10219f8a615..b28143256ec 100644 --- a/src/api/test-run-tracker.ts +++ b/src/api/test-run-tracker.ts @@ -66,21 +66,22 @@ class TestRunTracker extends EventEmitter { } } - public addTrackingMarkerToFunction (testRunId: string, fn: Function): Function { + public addTrackingMarkerToFunction (testRunId: string, fn: Function, context?: any): Function { const markerFactoryBody = ` - return function ${this.getMarkedFnName(testRunId)} () { + return function ${ this.getMarkedFnName(testRunId) } () { + context = context || this; switch (arguments.length) { - case 0: return fn.call(this); - case 1: return fn.call(this, arguments[0]); - case 2: return fn.call(this, arguments[0], arguments[1]); - case 3: return fn.call(this, arguments[0], arguments[1], arguments[2]); - case 4: return fn.call(this, arguments[0], arguments[1], arguments[2], arguments[3]); - default: return fn.apply(this, arguments); + case 0: return fn.call(context); + case 1: return fn.call(context, arguments[0]); + case 2: return fn.call(context, arguments[0], arguments[1]); + case 3: return fn.call(context, arguments[0], arguments[1], arguments[2]); + case 4: return fn.call(context, arguments[0], arguments[1], arguments[2], arguments[3]); + default: return fn.apply(context, arguments); } }; `; - return new Function('fn', markerFactoryBody)(fn); + return new Function('fn', 'context', markerFactoryBody)(fn, context); } public getContextTestRunId (): string | null { diff --git a/src/api/wrap-custom-action.ts b/src/api/wrap-custom-action.ts new file mode 100644 index 00000000000..6933cc1e78e --- /dev/null +++ b/src/api/wrap-custom-action.ts @@ -0,0 +1,12 @@ +import testRunTracker from './test-run-tracker'; +import wrapTestFunction, { WrapTestFunctionExecutorArguments } from './wrap-test-function'; + +export default function wrapCustomAction (fn: Function): Function { + const executor = async function ({ testRun, functionArgs }: WrapTestFunctionExecutorArguments): Promise { + const markeredfn = testRunTracker.addTrackingMarkerToFunction(testRun.id, fn, testRun.controller); + + return await markeredfn(...functionArgs); + }; + + return wrapTestFunction(fn, executor); +} diff --git a/src/api/wrap-test-function.ts b/src/api/wrap-test-function.ts index d234dab0d85..25cc7b5c294 100644 --- a/src/api/wrap-test-function.ts +++ b/src/api/wrap-test-function.ts @@ -1,50 +1,51 @@ -import TestController from './test-controller'; import testRunTracker from './test-run-tracker'; import TestRun from '../test-run'; +import TestController from './test-controller'; import TestCafeErrorList from '../errors/error-list'; import { MissingAwaitError } from '../errors/test-run'; import addRenderedWarning from '../notifications/add-rendered-warning'; import WARNING_MESSAGES from '../notifications/warning-message'; +import { addErrors, addWarnings } from './test-controller/add-message'; -export default function wrapTestFunction (fn: Function): Function { - return async (testRun: TestRun) => { - let result = null; - const errList = new TestCafeErrorList(); - const markeredfn = testRunTracker.addTrackingMarkerToFunction(testRun.id, fn); - - function addWarnings (callsiteSet: Set>, message: string): void { - callsiteSet.forEach(callsite => { - addRenderedWarning(testRun.warningLog, message, callsite); - callsiteSet.delete(callsite); - }); - } +export interface WrapTestFunctionExecutorArguments { + testRun: TestRun; + functionArgs: any[]; + fn: Function; +} - function addErrors (callsiteSet: Set>, ErrorClass: any): void { - callsiteSet.forEach(callsite => { - errList.addError(new ErrorClass(callsite)); - callsiteSet.delete(callsite); - }); - } +const defaultExecutor = async function ({ testRun, fn }: WrapTestFunctionExecutorArguments): Promise { + const markeredfn = testRunTracker.addTrackingMarkerToFunction(testRun.id, fn); + + return await markeredfn(testRun.controller); +}; + +export default function wrapTestFunction (fn: Function, executor: Function = defaultExecutor): Function { + return async (testRun: TestRun, functionArgs: any) => { + let result = null; + const errList = new TestCafeErrorList(); testRun.controller = new TestController(testRun); testRun.observedCallsites.clear(); - testRunTracker.ensureEnabled(); try { - result = await markeredfn(testRun.controller); + result = await executor({ fn, functionArgs, testRun }); } catch (err) { errList.addError(err); } if (!errList.hasUncaughtErrorsInTestCode) { - for (const { callsite, actionId } of testRun.observedCallsites.awaitedSnapshotWarnings.values()) - addRenderedWarning(testRun.warningLog, { message: WARNING_MESSAGES.excessiveAwaitInAssertion, actionId }, callsite); - - addWarnings(testRun.observedCallsites.unawaitedSnapshotCallsites, WARNING_MESSAGES.missingAwaitOnSnapshotProperty); - addErrors(testRun.observedCallsites.callsitesWithoutAwait, MissingAwaitError); + for (const { callsite, actionId } of testRun.observedCallsites.awaitedSnapshotWarnings.values()) { + addRenderedWarning(testRun.warningLog, { + message: WARNING_MESSAGES.excessiveAwaitInAssertion, + actionId, + }, callsite); + } + + addWarnings(testRun.observedCallsites.unawaitedSnapshotCallsites, WARNING_MESSAGES.missingAwaitOnSnapshotProperty, testRun); + addErrors(testRun.observedCallsites.callsitesWithoutAwait, MissingAwaitError, errList); } if (errList.hasErrors) @@ -53,3 +54,4 @@ export default function wrapTestFunction (fn: Function): Function { return result; }; } + diff --git a/src/configuration/option-names.ts b/src/configuration/option-names.ts index 4ff06d79590..e5e6f7ca9d2 100644 --- a/src/configuration/option-names.ts +++ b/src/configuration/option-names.ts @@ -56,6 +56,7 @@ enum OptionNames { dashboard = 'dashboard', baseUrl = 'baseUrl', disableCrossDomain = 'disableCrossDomain', + customActions = 'customActions', } export default OptionNames; diff --git a/src/configuration/types.ts b/src/configuration/types.ts index 9f230ba91e7..b3713b046d8 100644 --- a/src/configuration/types.ts +++ b/src/configuration/types.ts @@ -31,5 +31,7 @@ interface GlobalHooks { request?: typeof RequestHook[] | typeof RequestHook; } +type CustomActions = { [name: string]: () => void } + // eslint-disable-next-line @typescript-eslint/no-unused-vars -type OptionValue = undefined | null | string | boolean | number | string[] | Function | { [key: string]: any } | ScreenshotOptionValue | QuarantineOptionValue | CompilerOptions | GlobalHooks | SkipJsErrorsOptionValue; +type OptionValue = undefined | null | string | boolean | number | string[] | Function | { [key: string]: any } | ScreenshotOptionValue | QuarantineOptionValue | CompilerOptions | GlobalHooks | SkipJsErrorsOptionValue | CustomActions; diff --git a/src/errors/runtime/templates.js b/src/errors/runtime/templates.js index 4a1cdc8a58c..d7d3cbe3671 100644 --- a/src/errors/runtime/templates.js +++ b/src/errors/runtime/templates.js @@ -12,6 +12,7 @@ const DOCUMENTATION_LINKS = { TEST_SOURCE_PARAMETER: 'https://testcafe.io/documentation/402639/reference/command-line-interface#file-pathglob-pattern', FILTER_SETTINGS: 'https://testcafe.io/documentation/402638/reference/configuration-file#filter', HEADLESS_MODE: 'https://testcafe.io/documentation/402828/guides/concepts/browsers#test-in-headless-mode', + CUSTOM_ACTIONS: 'https://testcafe.io/documentation/404150/guides/advanced-guides/custom-test-actions', }; export default { @@ -139,4 +140,6 @@ export default { [RUNTIME_ERRORS.invalidSkipJsErrorsOptionsObjectProperty]: `The "{optionName}" option does not exist. Use the following options to configure skipJsErrors: ${getConcatenatedValuesString(Object.keys(SKIP_JS_ERRORS_OPTIONS_OBJECT_OPTION_NAMES))}.`, [RUNTIME_ERRORS.invalidSkipJsErrorsCallbackWithOptionsProperty]: `The "{optionName}" option does not exist. Use the following options to configure skipJsErrors callback: ${getConcatenatedValuesString(Object.keys(SKIP_JS_ERRORS_CALLBACK_WITH_OPTIONS_OPTION_NAMES))}.`, [RUNTIME_ERRORS.invalidCommandInJsonCompiler]: `TestCafe terminated the test run. The "{path}" file contains an unknown Chrome User Flow action "{action}". Remove the action to continue. Refer to the following article for the definitive list of supported Chrome User Flow actions: https://testcafe.io/documentation/403998/guides/experimental-capabilities/chrome-replay-support#supported-replay-actions`, + [RUNTIME_ERRORS.invalidCustomActionsOptionType]: `The value of the customActions option does not belong to type Object. Refer to the following article for custom action setup instructions: ${ DOCUMENTATION_LINKS.CUSTOM_ACTIONS }`, + [RUNTIME_ERRORS.invalidCustomActionType]: `TestCafe cannot parse the "{actionName}" action, because the action definition is invalid. Format the definition in accordance with the custom actions guide: ${ DOCUMENTATION_LINKS.CUSTOM_ACTIONS }`, }; diff --git a/src/errors/types.js b/src/errors/types.js index 56b68526e61..915ad37e0c6 100644 --- a/src/errors/types.js +++ b/src/errors/types.js @@ -182,4 +182,6 @@ export const RUNTIME_ERRORS = { invalidSkipJsErrorsOptionsObjectProperty: 'E1076', invalidSkipJsErrorsCallbackWithOptionsProperty: 'E1077', invalidCommandInJsonCompiler: 'E1078', + invalidCustomActionsOptionType: 'E1079', + invalidCustomActionType: 'E1080', }; diff --git a/src/reporter/command/command-formatter.ts b/src/reporter/command/command-formatter.ts index f81160131d0..b75f00b6076 100644 --- a/src/reporter/command/command-formatter.ts +++ b/src/reporter/command/command-formatter.ts @@ -3,6 +3,7 @@ import { ExecuteSelectorCommand, ExecuteClientFunctionCommand } from '../../test import { NavigateToCommand, PressKeyCommand, + RunCustomActionCommand, SetNativeDialogHandlerCommand, TypeTextCommand, UseRoleCommand, @@ -54,6 +55,9 @@ export class CommandFormatter { else this._assignProperties(this._command, formattedCommand); + if (this._command instanceof RunCustomActionCommand) + formattedCommand.actionResult = this._result; + this._maskConfidentialInfo(formattedCommand); return formattedCommand; diff --git a/src/runner/index.js b/src/runner/index.js index 3493eb31d76..c4c5655d65a 100644 --- a/src/runner/index.js +++ b/src/runner/index.js @@ -317,6 +317,21 @@ export default class Runner extends EventEmitter { validateSkipJsErrorsOptionValue(skipJsErrorsOptions, GeneralError); } + _validateCustomActionsOption () { + const customActions = this.configuration.getOption(OPTION_NAMES.customActions); + + if (!customActions) + return; + + if (typeof customActions !== 'object') + throw new GeneralError(RUNTIME_ERRORS.invalidCustomActionsOptionType); + + for (const name in customActions) { + if (typeof customActions[name] !== 'function') + throw new GeneralError(RUNTIME_ERRORS.invalidCustomActionType, name, typeof customActions[name]); + } + } + async _validateBrowsers () { const browsers = this.configuration.getOption(OPTION_NAMES.browsers); @@ -485,6 +500,7 @@ export default class Runner extends EventEmitter { this._validateQuarantineOptions(); this._validateConcurrencyOption(); this._validateSkipJsErrorsOption(); + this._validateCustomActionsOption(); await this._validateBrowsers(); } diff --git a/src/test-run/commands/actions.d.ts b/src/test-run/commands/actions.d.ts index 814ae58daf1..513f3a2fbcc 100644 --- a/src/test-run/commands/actions.d.ts +++ b/src/test-run/commands/actions.d.ts @@ -278,3 +278,10 @@ export class RemoveRequestHooksCommand extends ActionCommandBase { public hooks: RequestHook[]; } +export class RunCustomActionCommand extends ActionCommandBase { + public constructor (obj: object, testRun: TestRun, validateProperties: boolean); + public fn: Function; + public name: string; + public args: any; +} + diff --git a/src/test-run/commands/actions.js b/src/test-run/commands/actions.js index eb07f31011e..d8483ab963b 100644 --- a/src/test-run/commands/actions.js +++ b/src/test-run/commands/actions.js @@ -769,6 +769,22 @@ export class SkipJsErrorsCommand extends ActionCommandBase { } } +export class RunCustomActionCommand extends ActionCommandBase { + static methodName = camelCase(TYPE.runCustomAction); + + constructor (obj, testRun, validateProperties) { + super(obj, testRun, TYPE.runCustomAction, validateProperties); + } + + getAssignableProperties () { + return [ + { name: 'fn', type: functionArgument, required: true }, + { name: 'name', type: stringArgument, required: true }, + { name: 'args', required: false }, + ]; + } +} + export class AddRequestHooksCommand extends ActionCommandBase { static methodName = camelCase(TYPE.addRequestHooks); diff --git a/src/test-run/commands/type.js b/src/test-run/commands/type.js index 9d58636159b..8a63204aef2 100644 --- a/src/test-run/commands/type.js +++ b/src/test-run/commands/type.js @@ -71,4 +71,5 @@ export default { skipJsErrors: 'skip-js-errors', addRequestHooks: 'add-request-hooks', removeRequestHooks: 'remove-request-hooks', + runCustomAction: 'run-custom-action', }; diff --git a/src/test-run/index.ts b/src/test-run/index.ts index 1bbb88d5752..d27e995371d 100644 --- a/src/test-run/index.ts +++ b/src/test-run/index.ts @@ -77,6 +77,7 @@ import { DeleteCookiesCommand, AddRequestHooksCommand, RemoveRequestHooksCommand, + RunCustomActionCommand, } from './commands/actions'; import { RUNTIME_ERRORS, TEST_RUN_ERRORS } from '../errors/types'; @@ -129,6 +130,7 @@ import { CookieOptions } from './commands/options'; import { prepareSkipJsErrorsOptions } from '../api/skip-js-errors'; import { CookieProviderFactory } from './cookies/factory'; import { CookieProvider } from './cookies/base'; +import wrapCustomAction from '../api/wrap-custom-action'; import { ProxylessRoleProvider, @@ -1277,6 +1279,13 @@ export default class TestRun extends AsyncEventEmitter { return await fn(); } + if (command.type === COMMAND_TYPE.runCustomAction) { + const { fn, args } = command as RunCustomActionCommand; + const wrappedFn = wrapCustomAction(fn); + + return await wrappedFn(this, args); + } + if (command.type === COMMAND_TYPE.assertion) return this._executeAssertion(command as AssertionCommand, callsite as CallsiteRecord); diff --git a/test/functional/fixtures/custom-actions/actions.js b/test/functional/fixtures/custom-actions/actions.js new file mode 100644 index 00000000000..372fea10cf7 --- /dev/null +++ b/test/functional/fixtures/custom-actions/actions.js @@ -0,0 +1,30 @@ +const { Selector } = require('../../../../lib/api/exportable-lib'); + +async function clickBySelector (selector) { + await this.click(selector); +} + +async function getSpanTextBySelector (selector) { + return await Selector(selector).innerText; +} + +async function typeTextAndClickButton (inputSelector, buttonSelector, inputText) { + await this.typeText(inputSelector, inputText).click(buttonSelector); +} + +async function typeToInputAndCheckResult (inputSelector, buttonSelector, resultSelector, inputText) { + await this.customActions.typeTextAndClickButton(inputSelector, buttonSelector, inputText) + .expect(await this.customActions.getSpanTextBySelector(resultSelector)).eql(inputText); +} + +function getTextValue () { + return 'some text'; +} + +module.exports = { + getSpanTextBySelector, + clickBySelector, + typeTextAndClickButton, + typeToInputAndCheckResult, + getTextValue, +}; diff --git a/test/functional/fixtures/custom-actions/pages/index.html b/test/functional/fixtures/custom-actions/pages/index.html new file mode 100644 index 00000000000..96b43b922f8 --- /dev/null +++ b/test/functional/fixtures/custom-actions/pages/index.html @@ -0,0 +1,22 @@ + + + + + Page error + + + + + + + + + + diff --git a/test/functional/fixtures/custom-actions/test.js b/test/functional/fixtures/custom-actions/test.js new file mode 100644 index 00000000000..c3be930533d --- /dev/null +++ b/test/functional/fixtures/custom-actions/test.js @@ -0,0 +1,137 @@ +const { + clickBySelector, + getSpanTextBySelector, + typeTextAndClickButton, + typeToInputAndCheckResult, getTextValue, +} = require('./actions'); + +const { expect } = require('chai'); +const config = require('../../config'); +const { createReporter } = require('../../utils/reporter'); + +(config.experimentalDebug ? describe.skip : describe)('[API] Custom Actions', function () { + it('Should run custom click action', function () { + return runTests('./testcafe-fixtures/index.js', 'Should run custom click action', { customActions: { clickBySelector } }); + }); + + it('Should return value from custom action', function () { + return runTests('./testcafe-fixtures/index.js', 'Should return value from custom action', { + customActions: { + clickBySelector, + getSpanTextBySelector, + }, + }); + }); + + it('Should chain multiple actions inside custom action', function () { + return runTests('./testcafe-fixtures/index.js', 'Should chain multiple actions', { + customActions: { + typeTextAndClickButton, + }, + }); + }); + + it('Should run custom action inside another custom action', function () { + return runTests('./testcafe-fixtures/index.js', 'Should run custom action inside another custom action', { + customActions: { + typeToInputAndCheckResult, + typeTextAndClickButton, + getSpanTextBySelector, + }, + }); + }); + + it('Should run non-async custom action', function () { + return runTests('./testcafe-fixtures/index.js', 'Should run non-async custom action', { + customActions: { + getTextValue, + }, + }); + }); + + it('Should throw an exception inside custom action', function () { + return runTests('./testcafe-fixtures/index.js', 'Should throw an exception inside custom action', { + customActions: { clickBySelector }, + shouldFail: true, + }) + .catch(errs => { + expect(errs[0]).contains('The specified selector does not match any element in the DOM tree.'); + }); + }); + + it('Should throw an exception due to undefined action', function () { + return runTests('./testcafe-fixtures/index.js', 'Should throw an exception inside custom action', { + shouldFail: true, + }) + .catch(errs => { + expect(errs[0]).contains('TypeError: t.customActions.clickBySelector is not a function'); + }); + }); + + it('Should report all actions in correct order', function () { + function ReporterRecord (phase, actionName, command) { + this.phase = phase; + this.actionName = actionName; + if (command.type !== 'run-custom-action') + return this; + + delete command.actionId; + delete command.fn; + delete command.args; + + this.command = command; + } + + const result = {}; + const expectedResult = [ + { phase: 'start', actionName: 'runCustomAction', command: { type: 'run-custom-action', name: 'typeToInputAndCheckResult', actionResult: void 0 } }, + { phase: 'start', actionName: 'runCustomAction', command: { type: 'run-custom-action', name: 'typeTextAndClickButton', actionResult: void 0 } }, + { phase: 'start', actionName: 'typeText' }, + { phase: 'end', actionName: 'typeText' }, + { phase: 'start', actionName: 'click' }, + { phase: 'end', actionName: 'click' }, + { phase: 'end', actionName: 'runCustomAction', command: { type: 'run-custom-action', name: 'typeTextAndClickButton', actionResult: void 0 } }, + { phase: 'start', actionName: 'runCustomAction', command: { type: 'run-custom-action', name: 'getSpanTextBySelector', actionResult: void 0 } }, + { phase: 'start', actionName: 'execute-selector' }, + { phase: 'end', actionName: 'execute-selector' }, + { + phase: 'end', + actionName: 'runCustomAction', + command: { + type: 'run-custom-action', + name: 'getSpanTextBySelector', + actionResult: 'Some text', + }, + }, + { phase: 'start', actionName: 'eql' }, + { phase: 'end', actionName: 'eql' }, + { phase: 'end', actionName: 'runCustomAction', command: { type: 'run-custom-action', name: 'typeToInputAndCheckResult', actionResult: void 0 } }, + ]; + + function addTestRecord ( phase, testRunId, name, command) { + if (!result[testRunId]) + result[testRunId] = []; + + result[testRunId].push(new ReporterRecord(phase, name, command)); + } + + const reporter = createReporter({ + reportTestActionStart: (name, { command, testRunId }) => addTestRecord('start', testRunId, name, command), + reportTestActionDone: (name, { command, testRunId }) => addTestRecord('end', testRunId, name, command), + }); + + return runTests('./testcafe-fixtures/index.js', 'Should run custom action inside another custom action', { + customActions: { + typeToInputAndCheckResult, + typeTextAndClickButton, + getSpanTextBySelector, + }, + reporter, + }).then(() => { + Object.values(result).map(res => { + expect(res).to.deep.equal(expectedResult); + }); + }); + }); +}); + diff --git a/test/functional/fixtures/custom-actions/testcafe-fixtures/index.js b/test/functional/fixtures/custom-actions/testcafe-fixtures/index.js new file mode 100644 index 00000000000..d34158409ab --- /dev/null +++ b/test/functional/fixtures/custom-actions/testcafe-fixtures/index.js @@ -0,0 +1,35 @@ +import { Selector } from 'testcafe'; + +fixture`Custom Actions` + .page`http://localhost:3000/fixtures/custom-actions/pages/index.html`; + +test('Should run custom click action', async t => { + await t.customActions.clickBySelector('#button1'); +}); + +test('Should return value from custom action', async t => { + const before = await t.customActions.getSpanTextBySelector('#result1'); + const after = await t.customActions.clickBySelector('#button1') + .customActions.getSpanTextBySelector('#result1'); + + await t.expect(before).eql('').expect(after).eql('OK'); +}); + +test('Should chain multiple actions', async t => { + await t.customActions.typeTextAndClickButton('#input1', '#button2', 'Some text') + .expect(Selector('#result2').innerText).eql('Some text'); +}); + +test('Should run custom action inside another custom action', async t => { + await t.customActions.typeToInputAndCheckResult('#input1', '#button2', '#result2', 'Some text'); +}); + +test('Should run non-async custom action', async t => { + const result = await t.customActions.getTextValue(); + + await t.expect(result).eql('some text'); +}); + +test('Should throw an exception inside custom action', async t => { + await t.customActions.clickBySelector('blablabla'); +}); diff --git a/test/functional/setup.js b/test/functional/setup.js index 5b037084254..2bc4ba393c7 100644 --- a/test/functional/setup.js +++ b/test/functional/setup.js @@ -239,6 +239,7 @@ before(function () { testExecutionTimeout, runExecutionTimeout, baseUrl, + customActions, } = opts; const actualBrowsers = browsersInfo.filter(browserInfo => { @@ -309,6 +310,7 @@ before(function () { testExecutionTimeout, runExecutionTimeout, baseUrl, + customActions, }) .then(failedCount => { if (customReporters) diff --git a/test/server/configuration-test.js b/test/server/configuration-test.js index 8cd371e37af..71d46667d67 100644 --- a/test/server/configuration-test.js +++ b/test/server/configuration-test.js @@ -552,6 +552,21 @@ describe('TestCafeConfiguration', function () { expect(configuration.getOption('hooks')).to.equal(clone.getOption('hooks')); }); }); + + describe('[API] CustomActions', () => { + it('Should get custom actions from the JS Configuration file property', async () => { + const customConfigFilePath = './test/server/data/custom-actions/config.js'; + + const configuration = new TestCafeConfiguration(customConfigFilePath); + + await configuration.init(); + + const customActions = configuration.getOption('customActions'); + + expect(customActions.makeSomething).to.be.a('function'); + expect(customActions.doSomething).to.be.a('function'); + }); + }); }); describe('Default values', function () { diff --git a/test/server/data/custom-actions/config.js b/test/server/data/custom-actions/config.js new file mode 100644 index 00000000000..d29f9a21165 --- /dev/null +++ b/test/server/data/custom-actions/config.js @@ -0,0 +1,11 @@ +module.exports = { + customActions: { + async makeSomething () { + await this.click(); + }, + async doSomething () { + await this.custom.makeSomething(); + }, + fake: 'some fake data' + } +} diff --git a/test/server/runner-test.js b/test/server/runner-test.js index c626c734ac4..f3fa74de08f 100644 --- a/test/server/runner-test.js +++ b/test/server/runner-test.js @@ -1336,6 +1336,41 @@ describe('Runner', () => { 'The testRun.globalAfter hook (string) is not of expected type (function).'); } }); + + it('Should raise an error if customActions property belongs to invalid type', async function () { + try { + await runner + .browsers(connection) + .src('test/server/data/test-suites/basic/testfile2.js') + .run({ + customActions: 'string', + }); + + throw new Error('Promise rejection expected'); + } + catch (err) { + expect(err.message).eql('The value of the customActions option does not belong to type Object. Refer to the following article for custom action setup instructions: https://testcafe.io/documentation/404150/guides/advanced-guides/custom-test-actions'); + } + }); + + it('Should raise an error if some of custom actions belongs to invalid type', async function () { + try { + await runner + .browsers(connection) + .src('test/server/data/test-suites/basic/testfile2.js') + .run({ + customActions: { + makeCoffee () { }, + makeTea: 'string', + }, + }); + + throw new Error('Promise rejection expected'); + } + catch (err) { + expect(err.message).eql('TestCafe cannot parse the "makeTea" action, because the action definition is invalid. Format the definition in accordance with the custom actions guide: https://testcafe.io/documentation/404150/guides/advanced-guides/custom-test-actions'); + } + }); }); describe('.clientScripts', () => {