diff --git a/autotests/tests/internalTypeTests/selectors.skip.ts b/autotests/tests/internalTypeTests/selectors.skip.ts new file mode 100644 index 00000000..7697b5fe --- /dev/null +++ b/autotests/tests/internalTypeTests/selectors.skip.ts @@ -0,0 +1,62 @@ +import { + htmlElementSelector, + createSelector, + locatorIdSelector, + createSelectorByCss, +} from 'e2ed/selectors'; + +// @ts-expect-error: wrong number of arguments +htmlElementSelector.findByTestId(); +// @ts-expect-error: wrong type of arguments +htmlElementSelector.findByTestId(0); +// ok +htmlElementSelector.findByTestId('id'); +// ok +htmlElementSelector.findByTestId('id').findByTestId('id2'); +// ok +htmlElementSelector.findByTestId('id').find('.test-children'); +// ok +htmlElementSelector.find('body').findByTestId('id'); + +// ok +createSelector('id').findByTestId('id').find('body').findByTestId('id'); +// ok +createSelectorByCss('id').findByTestId('id').find('body').findByTestId('id'); +// ok +locatorIdSelector('id').findByTestId('id').find('body').findByTestId('id'); + +// ok +htmlElementSelector.filterByTestId('id'); +// ok +htmlElementSelector.parentByTestId('id'); +// ok +htmlElementSelector.childByTestId('id'); +// ok +htmlElementSelector.siblingByTestId('id'); +// ok +htmlElementSelector.nextSiblingByTestId('id'); +// ok +htmlElementSelector.prevSiblingByTestId('id'); + +// ok +htmlElementSelector.filterByTestProp('prop', 'value'); +// ok +htmlElementSelector.findByTestProp('prop', 'value'); +// ok +htmlElementSelector.parentByTestProp('prop', 'value'); +// ok +htmlElementSelector.childByTestProp('prop', 'value'); +// ok +htmlElementSelector.siblingByTestProp('prop', 'value'); +// ok +htmlElementSelector.nextSiblingByTestProp('prop', 'value'); +// ok +htmlElementSelector.prevSiblingByTestProp('prop', 'value'); + +// ok +htmlElementSelector.getTestProp('prop'); +// ok +htmlElementSelector.hasTestProp('prop'); + +// ok +htmlElementSelector.getDescription(); diff --git a/src/actions/asserts/viewport/assertSelectorEntirelyInViewport.ts b/src/actions/asserts/viewport/assertSelectorEntirelyInViewport.ts index 6059f291..9021b2f1 100644 --- a/src/actions/asserts/viewport/assertSelectorEntirelyInViewport.ts +++ b/src/actions/asserts/viewport/assertSelectorEntirelyInViewport.ts @@ -1,6 +1,5 @@ import {LogEventType} from '../../../constants/internal'; import {expect} from '../../../expect'; -import {getDescriptionFromSelector} from '../../../utils/locators'; import {log} from '../../../utils/log'; import {isSelectorEntirelyInViewport} from '../../../utils/viewport'; @@ -12,7 +11,7 @@ import type {Selector} from '../../../types/internal'; */ export const assertSelectorEntirelyInViewport = async (selector: Selector): Promise => { const isEntirelyInViewport = await isSelectorEntirelyInViewport(selector); - const locator = getDescriptionFromSelector(selector); + const locator = selector.getDescription(); const message = 'selector is entirely in the viewport'; log(`Asserts that ${message}`, {locator}, LogEventType.InternalAssert); diff --git a/src/actions/asserts/viewport/assertSelectorInViewport.ts b/src/actions/asserts/viewport/assertSelectorInViewport.ts index 15dc0979..c0b1f072 100644 --- a/src/actions/asserts/viewport/assertSelectorInViewport.ts +++ b/src/actions/asserts/viewport/assertSelectorInViewport.ts @@ -1,6 +1,5 @@ import {LogEventType} from '../../../constants/internal'; import {expect} from '../../../expect'; -import {getDescriptionFromSelector} from '../../../utils/locators'; import {log} from '../../../utils/log'; import {isSelectorInViewport} from '../../../utils/viewport'; @@ -12,7 +11,7 @@ import type {Selector} from '../../../types/internal'; */ export const assertSelectorInViewport = async (selector: Selector): Promise => { const isInViewport = await isSelectorInViewport(selector); - const locator = getDescriptionFromSelector(selector); + const locator = selector.getDescription(); const message = 'selector is in the viewport'; log(`Asserts that ${message}`, {locator}, LogEventType.InternalAssert); diff --git a/src/actions/clearUpload.ts b/src/actions/clearUpload.ts index a2f8813f..d455c294 100644 --- a/src/actions/clearUpload.ts +++ b/src/actions/clearUpload.ts @@ -1,6 +1,5 @@ import {LogEventType} from '../constants/internal'; import {testController} from '../testController'; -import {getDescriptionFromSelector} from '../utils/locators'; import {log} from '../utils/log'; import type {Selector, TestCafeSelector} from '../types/internal'; @@ -9,7 +8,7 @@ import type {Selector, TestCafeSelector} from '../types/internal'; * Removes all file paths from the specified file upload input. */ export const clearUpload = (selector: Selector): Promise => { - const locator = getDescriptionFromSelector(selector); + const locator = selector.getDescription(); log('Remove all file paths from file upload input', {locator}, LogEventType.InternalAction); diff --git a/src/actions/click.ts b/src/actions/click.ts index 424a212a..171a67b6 100644 --- a/src/actions/click.ts +++ b/src/actions/click.ts @@ -1,6 +1,5 @@ import {LogEventType} from '../constants/internal'; import {testController} from '../testController'; -import {getDescriptionFromSelector} from '../utils/locators'; import {log} from '../utils/log'; import {waitForInterfaceStabilization} from './waitFor'; @@ -16,7 +15,7 @@ export const click = async ( selector: Selector, {stabilizationInterval, ...options}: Options = {}, ): Promise => { - const locator = getDescriptionFromSelector(selector); + const locator = selector.getDescription(); const withLocator = locator ? ` with locator ${locator}` : ''; log( diff --git a/src/actions/dispatchEvent.ts b/src/actions/dispatchEvent.ts index 223d37e0..6a5bf6c1 100644 --- a/src/actions/dispatchEvent.ts +++ b/src/actions/dispatchEvent.ts @@ -1,6 +1,5 @@ import {LogEventType} from '../constants/internal'; import {testController} from '../testController'; -import {getDescriptionFromSelector} from '../utils/locators'; import {log} from '../utils/log'; import {waitForInterfaceStabilization} from './waitFor'; @@ -17,7 +16,7 @@ export const dispatchEvent = async ( eventName: string, {stabilizationInterval, ...options}: Options = {}, ): Promise => { - const locator = getDescriptionFromSelector(selector); + const locator = selector.getDescription(); log( 'Dispatches an event over a specified element', diff --git a/src/actions/doubleClick.ts b/src/actions/doubleClick.ts index 3518e71f..0edcd96b 100644 --- a/src/actions/doubleClick.ts +++ b/src/actions/doubleClick.ts @@ -1,6 +1,5 @@ import {LogEventType} from '../constants/internal'; import {testController} from '../testController'; -import {getDescriptionFromSelector} from '../utils/locators'; import {log} from '../utils/log'; import {waitForInterfaceStabilization} from './waitFor'; @@ -16,7 +15,7 @@ export const doubleClick = async ( selector: Selector, {stabilizationInterval, ...options}: Options = {}, ): Promise => { - const locator = getDescriptionFromSelector(selector); + const locator = selector.getDescription(); const withLocator = locator ? ` with locator ${locator}` : ''; log( diff --git a/src/actions/drag.ts b/src/actions/drag.ts index d17c71d1..5f3536f3 100644 --- a/src/actions/drag.ts +++ b/src/actions/drag.ts @@ -1,6 +1,5 @@ import {LogEventType} from '../constants/internal'; import {testController} from '../testController'; -import {getDescriptionFromSelector} from '../utils/locators'; import {log} from '../utils/log'; import {waitForInterfaceStabilization} from './waitFor'; @@ -19,7 +18,7 @@ export const drag = async ( {stabilizationInterval, ...options}: Options = {}, // eslint-disable-next-line max-params ): Promise => { - const locator = getDescriptionFromSelector(selector); + const locator = selector.getDescription(); log( 'Drag an element by an offset', diff --git a/src/actions/dragToElement.ts b/src/actions/dragToElement.ts index 6a69c5db..713ba022 100644 --- a/src/actions/dragToElement.ts +++ b/src/actions/dragToElement.ts @@ -1,6 +1,5 @@ import {LogEventType} from '../constants/internal'; import {testController} from '../testController'; -import {getDescriptionFromSelector} from '../utils/locators'; import {log} from '../utils/log'; import {waitForInterfaceStabilization} from './waitFor'; @@ -17,8 +16,8 @@ export const dragToElement = async ( destinationSelector: Selector, {stabilizationInterval, ...options}: Options = {}, ): Promise => { - const locator = getDescriptionFromSelector(selector); - const destinationLocator = getDescriptionFromSelector(destinationSelector); + const locator = selector.getDescription(); + const destinationLocator = destinationSelector.getDescription(); log( 'Drag an element onto another one', diff --git a/src/actions/hover.ts b/src/actions/hover.ts index aa87975e..ea56a70c 100644 --- a/src/actions/hover.ts +++ b/src/actions/hover.ts @@ -1,6 +1,5 @@ import {LogEventType} from '../constants/internal'; import {testController} from '../testController'; -import {getDescriptionFromSelector} from '../utils/locators'; import {log} from '../utils/log'; import {waitForInterfaceStabilization} from './waitFor'; @@ -16,7 +15,7 @@ export const hover = async ( selector: Selector, {stabilizationInterval, ...options}: Options = {}, ): Promise => { - const locator = getDescriptionFromSelector(selector); + const locator = selector.getDescription(); log( 'Hover the mouse pointer over an element', diff --git a/src/actions/rightClick.ts b/src/actions/rightClick.ts index 5a6157bd..a12e7d84 100644 --- a/src/actions/rightClick.ts +++ b/src/actions/rightClick.ts @@ -1,6 +1,5 @@ import {LogEventType} from '../constants/internal'; import {testController} from '../testController'; -import {getDescriptionFromSelector} from '../utils/locators'; import {log} from '../utils/log'; import {waitForInterfaceStabilization} from './waitFor'; @@ -16,7 +15,7 @@ export const rightClick = async ( selector: Selector, {stabilizationInterval, ...options}: Options = {}, ): Promise => { - const locator = getDescriptionFromSelector(selector); + const locator = selector.getDescription(); const withLocator = locator ? ` with locator ${locator}` : ''; log( diff --git a/src/actions/scroll.ts b/src/actions/scroll.ts index 43a269e7..41113719 100644 --- a/src/actions/scroll.ts +++ b/src/actions/scroll.ts @@ -1,6 +1,5 @@ import {LogEventType} from '../constants/internal'; import {testController} from '../testController'; -import {getDescriptionFromSelector} from '../utils/locators'; import {log} from '../utils/log'; import type {Inner} from 'testcafe-without-typecheck'; @@ -26,7 +25,7 @@ type Scroll = ((posX: number, posY: number) => Promise) & */ // @ts-expect-error: e2ed Selector type is incompatible with TS Selector export const scroll: Scroll = (...args) => { - const locator = getDescriptionFromSelector(args[0] as Selector); + const locator = (args[0] as Selector)?.getDescription(); const printedArgs = [...args]; if (typeof args[0] === 'object') { diff --git a/src/actions/scrollBy.ts b/src/actions/scrollBy.ts index 1344d7e1..09cb42ff 100644 --- a/src/actions/scrollBy.ts +++ b/src/actions/scrollBy.ts @@ -1,6 +1,5 @@ import {LogEventType} from '../constants/internal'; import {testController} from '../testController'; -import {getDescriptionFromSelector} from '../utils/locators'; import {log} from '../utils/log'; import type {Inner} from 'testcafe-without-typecheck'; @@ -15,7 +14,7 @@ type ScrollBy = ((x: number, y: number) => Promise) & */ // @ts-expect-error: e2ed Selector type is incompatible with TS Selector export const scrollBy: ScrollBy = (...args) => { - const locator = getDescriptionFromSelector(args[0] as Selector); + const locator = (args[0] as Selector)?.getDescription(); const printedArgs = [...args]; if (typeof args[0] === 'object') { diff --git a/src/actions/scrollIntoView.ts b/src/actions/scrollIntoView.ts index 6d6fcacd..bbbe49df 100644 --- a/src/actions/scrollIntoView.ts +++ b/src/actions/scrollIntoView.ts @@ -1,6 +1,5 @@ import {LogEventType} from '../constants/internal'; import {testController} from '../testController'; -import {getDescriptionFromSelector} from '../utils/locators'; import {log} from '../utils/log'; import type {Selector, TestCafeSelector} from '../types/internal'; @@ -11,7 +10,7 @@ type Options = Parameters[1]; * Scrolls the specified element into view. */ export const scrollIntoView = (selector: Selector, options?: Options): Promise => { - const locator = getDescriptionFromSelector(selector); + const locator = selector.getDescription(); log('Scroll the specified element into view', {locator, options}, LogEventType.InternalAction); diff --git a/src/actions/selectText.ts b/src/actions/selectText.ts index 80c8b8c0..45ddf136 100644 --- a/src/actions/selectText.ts +++ b/src/actions/selectText.ts @@ -1,6 +1,5 @@ import {LogEventType} from '../constants/internal'; import {testController} from '../testController'; -import {getDescriptionFromSelector} from '../utils/locators'; import {log} from '../utils/log'; import type {Selector, TestCafeSelector} from '../types/internal'; @@ -17,7 +16,7 @@ export const selectText = ( options?: Options, // eslint-disable-next-line max-params ): Promise => { - const locator = getDescriptionFromSelector(selector); + const locator = selector.getDescription(); log( `Select text in input element, from ${startPos} to ${ diff --git a/src/actions/setFilesToUpload.ts b/src/actions/setFilesToUpload.ts index 4b17647f..995fc76e 100644 --- a/src/actions/setFilesToUpload.ts +++ b/src/actions/setFilesToUpload.ts @@ -1,6 +1,5 @@ import {LogEventType} from '../constants/internal'; import {testController} from '../testController'; -import {getDescriptionFromSelector} from '../utils/locators'; import {log} from '../utils/log'; import type {Selector, TestCafeSelector} from '../types/internal'; @@ -13,7 +12,7 @@ export const setFilesToUpload = ( filePath: string | string[], ): Promise => { const hasManyFiles = Array.isArray(filePath) && filePath.length > 0; - const locator = getDescriptionFromSelector(selector); + const locator = selector.getDescription(); log( `Populate file upload input with file${hasManyFiles ? 's' : ''} "${String(filePath)}"`, diff --git a/src/actions/switchToIframe.ts b/src/actions/switchToIframe.ts index 3692111a..7b381534 100644 --- a/src/actions/switchToIframe.ts +++ b/src/actions/switchToIframe.ts @@ -1,6 +1,5 @@ import {LogEventType} from '../constants/internal'; import {testController} from '../testController'; -import {getDescriptionFromSelector} from '../utils/locators'; import {log} from '../utils/log'; import {waitForInterfaceStabilization} from './waitFor'; @@ -14,7 +13,7 @@ export const switchToIframe = async ( iframeSelector: Selector, {stabilizationInterval}: WithStabilizationInterval = {}, ): Promise => { - const locator = getDescriptionFromSelector(iframeSelector); + const locator = iframeSelector.getDescription(); log( 'Switch browsing context to the specified iframe', diff --git a/src/actions/takeElementScreenshot.ts b/src/actions/takeElementScreenshot.ts index 0208e320..cc533922 100644 --- a/src/actions/takeElementScreenshot.ts +++ b/src/actions/takeElementScreenshot.ts @@ -1,6 +1,5 @@ import {LogEventType} from '../constants/internal'; import {testController} from '../testController'; -import {getDescriptionFromSelector} from '../utils/locators'; import {log} from '../utils/log'; import type {Selector, TestCafeSelector} from '../types/internal'; @@ -15,7 +14,7 @@ export const takeElementScreenshot = ( pathToScreenshot?: string, options?: Options, ): Promise => { - const locator = getDescriptionFromSelector(selector); + const locator = selector.getDescription(); log( 'Take a screenshot of the element', diff --git a/src/actions/typeText.ts b/src/actions/typeText.ts index 953592c2..560bc915 100644 --- a/src/actions/typeText.ts +++ b/src/actions/typeText.ts @@ -1,6 +1,5 @@ import {LogEventType} from '../constants/internal'; import {testController} from '../testController'; -import {getDescriptionFromSelector} from '../utils/locators'; import {log} from '../utils/log'; import {waitForInterfaceStabilization} from './waitFor'; @@ -17,7 +16,7 @@ export const typeText = async ( text: string, {stabilizationInterval, ...options}: Options = {}, ): Promise => { - const locator = getDescriptionFromSelector(selector); + const locator = selector.getDescription(); log( `Type "${text}" into an input element`, diff --git a/src/createSelector.ts b/src/createSelector.ts deleted file mode 100644 index d6b733b8..00000000 --- a/src/createSelector.ts +++ /dev/null @@ -1,70 +0,0 @@ -import {Selector} from 'testcafe-without-typecheck'; - -import {DESCRIPTION_KEY} from './constants/internal'; - -import type {Fn, Selector as SelectorType, Values} from './types/internal'; - -/** - * Proxy handler for wrapping all selector properties. - */ -const get: Required>['get'] = (target, property, receiver) => { - let result = Reflect.get(target, property, receiver) as Values & { - [DESCRIPTION_KEY]?: string; - }; - - if (typeof property === 'symbol') { - return result; - } - - const locator = target[DESCRIPTION_KEY] || ''; - - if ((typeof result !== 'object' || result === null) && typeof result !== 'function') { - return result; - } - - if (typeof result === 'function') { - const originalFunction: Fn = result; - - result = // eslint-disable-next-line no-restricted-syntax - function selectorMethodWrapper(this: SelectorType, ...args: never[]) { - const callResult = originalFunction.apply(this, args); - - if ( - (typeof callResult !== 'object' || callResult === null) && - typeof callResult !== 'function' - ) { - return callResult; - } - - const callLocator = `${result[DESCRIPTION_KEY]}(${args.join(', ')})`; - - (callResult as typeof result)[DESCRIPTION_KEY] = callLocator; - - if (Object.prototype.hasOwnProperty.call(callResult, 'getBoundingClientRectProperty')) { - return new Proxy(callResult, {get}); - } - - return callResult; - } as typeof result; - } - - result[DESCRIPTION_KEY] = `${locator}.${property}`; - - return result; -}; - -/** - * Creates selector by locator and optional parameters. - */ -export const createSelector = (...args: Parameters): SelectorType => { - const locator = args[0]; - const selector = Selector(...args) as SelectorType; - - if (typeof locator !== 'string') { - return selector; - } - - selector[DESCRIPTION_KEY] = locator; - - return new Proxy(selector, {get}); -}; diff --git a/src/index.ts b/src/index.ts index bdc51cde..33fd123a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,7 @@ * Base public modules. */ export {ApiRoute} from './ApiRoute'; -export {createSelector} from './createSelector'; +export {createSelector} from './selectors'; export {Page} from './Page'; export {PageRoute} from './PageRoute'; export {Route} from './Route'; diff --git a/src/selectors/createSelector.ts b/src/selectors/createSelector.ts new file mode 100644 index 00000000..bf58c459 --- /dev/null +++ b/src/selectors/createSelector.ts @@ -0,0 +1,89 @@ +import {Selector} from 'testcafe-without-typecheck'; + +import {DESCRIPTION_KEY} from '../constants/internal'; + +import type {Fn, RawSelector, SelectorCustomMethods, Values} from '../types/internal'; + +/** + * Proxy handler for wrapping all selector properties. + */ +const createGet = ( + customMethods?: CustomMethods, +): Required>>['get'] => { + const get: Required>>['get'] = ( + target, + property, + receiver, + ) => { + const customMethod = + customMethods && typeof property === 'string' ? customMethods[property] : undefined; + + let result = ( + customMethod ? customMethod.bind(target) : Reflect.get(target, property, receiver) + ) as Values> & { + [DESCRIPTION_KEY]?: string; + }; + + if (typeof property === 'symbol') { + return result; + } + + if ((typeof result !== 'object' || result === null) && typeof result !== 'function') { + return result; + } + + if (typeof result === 'function') { + const originalFunction = result as Fn; + + result = // eslint-disable-next-line no-restricted-syntax + function selectorMethodWrapper(this: RawSelector, ...args: never[]) { + const callResult = originalFunction.apply(this, args); + + if ( + (typeof callResult !== 'object' || callResult === null) && + typeof callResult !== 'function' + ) { + return callResult; + } + + const callLocator = `${result[DESCRIPTION_KEY]}(${args.join(', ')})`; + + (callResult as typeof result)[DESCRIPTION_KEY] = callLocator; + + // callResult is Selector + if (Object.prototype.hasOwnProperty.call(callResult, 'getBoundingClientRectProperty')) { + return new Proxy(callResult, {get}); + } + + return callResult; + } as typeof result; + } + + const locator = target[DESCRIPTION_KEY] || ''; + + result[DESCRIPTION_KEY] = `${locator}.${property}`; + + return result; + }; + + return get; +}; + +export type CreateSelector = ( + ...args: Parameters +) => RawSelector; + +export const createSelectorCreator = ( + customMethods?: CustomMethods, +): CreateSelector => { + return function createSelector(...args) { + const locator = args[0]; + const selector = Selector(...args) as unknown as RawSelector; + + if (typeof locator === 'string') { + selector[DESCRIPTION_KEY] = locator; + } + + return new Proxy(selector, {get: createGet(customMethods)}); + }; +}; diff --git a/src/selectors/createSelectorByCss.ts b/src/selectors/createSelectorByCss.ts index 71e785ca..8d9cdd5d 100644 --- a/src/selectors/createSelectorByCss.ts +++ b/src/selectors/createSelectorByCss.ts @@ -1,9 +1,14 @@ -import {createSelector} from '../createSelector'; +import {CreateSelector} from './createSelector'; -import type {Selector} from '../types/internal'; +import type {RawSelector, SelectorCustomMethods} from '../types/internal'; -/** - * Creates selector of page elements by CSS selector. - */ -export const createSelectorByCss = (cssSelectorString: string): Selector => - createSelector(cssSelectorString); +export const createSelectorByCssCreator = ( + createSelector: CreateSelector, +): ((cssSelectorString: string) => RawSelector) => { + /** + * Creates selector of page elements by CSS selector. + */ + return function createSelectorByCss(cssSelectorString) { + return createSelector(cssSelectorString); + }; +}; diff --git a/src/selectors/createSelectors.ts b/src/selectors/createSelectors.ts new file mode 100644 index 00000000..98132c1a --- /dev/null +++ b/src/selectors/createSelectors.ts @@ -0,0 +1,39 @@ +import type {CreateSelectorsOptions, SelectorCustomMethods} from '../types/internal'; +import {createSelectorCreator} from './createSelector'; +import {createSelectorByCssCreator} from './createSelectorByCss'; +import {htmlElementSelectorCreator} from './htmlElementSelector'; +import {locatorIdSelectorCreator} from './locatorIdSelector'; +import {createDefaultCustomMethods} from './defaultCustomMethods'; +import {GetTestAttrName} from '../types/internal'; + +const createSelectorsWithCustomMethods = ( + getTestAttrName: GetTestAttrName, + // force `this` to be Selector + customMethods?: CustomMethods, +): typeof selectorsWithCustomMethods => { + const createSelector = createSelectorCreator(customMethods); + + const selectorsWithCustomMethods = { + /** + * Creates selector by locator and optional parameters. + */ + createSelector, + /** + * Creates selector of page elements by CSS selector. + */ + createSelectorByCss: createSelectorByCssCreator(createSelector), + /** + * Selector of page HTML element ("documentElement"). + */ + htmlElementSelector: htmlElementSelectorCreator(createSelector), + /** + * Selector of locator elements by locator id. + */ + locatorIdSelector: locatorIdSelectorCreator(createSelector, getTestAttrName), + }; + + return selectorsWithCustomMethods; +}; + +export const createSelectors = ({getTestAttrName}: CreateSelectorsOptions) => + createSelectorsWithCustomMethods(getTestAttrName, createDefaultCustomMethods(getTestAttrName)); diff --git a/src/selectors/defaultCustomMethods.ts b/src/selectors/defaultCustomMethods.ts new file mode 100644 index 00000000..ecc3d6ec --- /dev/null +++ b/src/selectors/defaultCustomMethods.ts @@ -0,0 +1,65 @@ +import {GetTestAttrName, SelectorDefaultCustomMethods} from '../types/internal'; +import {DESCRIPTION_KEY} from '../constants/selector'; + +export const createDefaultCustomMethods = ( + getTestAttrName: GetTestAttrName, +): SelectorDefaultCustomMethods => { + const testIdAttrName = getTestAttrName('id'); + + return { + filterByTestId(testId) { + return this.filter(`[${testIdAttrName}="${testId}"`); + }, + findByTestId(testId) { + return this.find(`[${testIdAttrName}="${testId}"`); + }, + parentByTestId(testId) { + return this.parent(`[${testIdAttrName}="${testId}"`); + }, + childByTestId(testId) { + return this.child(`[${testIdAttrName}="${testId}"`); + }, + siblingByTestId(testId) { + return this.sibling(`[${testIdAttrName}="${testId}"`); + }, + nextSiblingByTestId(testId) { + return this.nextSibling(`[${testIdAttrName}="${testId}"`); + }, + prevSiblingByTestId(testId) { + return this.prevSibling(`[${testIdAttrName}="${testId}"`); + }, + + filterByTestProp(property, value) { + return this.filter(`[${getTestAttrName(property)}="${value}"`); + }, + findByTestProp(property, value) { + return this.find(`[${getTestAttrName(property)}="${value}"`); + }, + parentByTestProp(property, value) { + return this.parent(`[${getTestAttrName(property)}="${value}"`); + }, + childByTestProp(property, value) { + return this.child(`[${getTestAttrName(property)}="${value}"`); + }, + siblingByTestProp(property, value) { + return this.sibling(`[${getTestAttrName(property)}="${value}"`); + }, + nextSiblingByTestProp(property, value) { + return this.nextSibling(`[${getTestAttrName(property)}="${value}"`); + }, + prevSiblingByTestProp(property, value) { + return this.prevSibling(`[${getTestAttrName(property)}="${value}"`); + }, + + getTestProp(property) { + return this.getAttribute(getTestAttrName(property)); + }, + hasTestProp(property) { + return this.hasAttribute(getTestAttrName(property)); + }, + + getDescription(): string | undefined { + return this[DESCRIPTION_KEY] as string | undefined; + }, + }; +}; diff --git a/src/selectors/htmlElementSelector.ts b/src/selectors/htmlElementSelector.ts index 2ad4d79c..1b99fd7e 100644 --- a/src/selectors/htmlElementSelector.ts +++ b/src/selectors/htmlElementSelector.ts @@ -1,6 +1,11 @@ -import {createSelectorByCss} from './createSelectorByCss'; +import type {RawSelector, SelectorCustomMethods} from '../types/internal'; +import type {CreateSelector} from './createSelector'; -/** - * Selector of page HTML element ("documentElement"). - */ -export const htmlElementSelector = createSelectorByCss('html'); +export const htmlElementSelectorCreator = ( + createSelector: CreateSelector, +): RawSelector => { + /** + * Selector of page HTML element ("documentElement"). + */ + return createSelector('html'); +}; diff --git a/src/selectors/index.ts b/src/selectors/index.ts index 34e98d05..e27458e4 100644 --- a/src/selectors/index.ts +++ b/src/selectors/index.ts @@ -1,10 +1,8 @@ -export {createSelectorByCss} from './createSelectorByCss'; -export {htmlElementSelector} from './htmlElementSelector'; -export {locatorIdInParentSelector} from './locatorIdInParentSelector'; -export {locatorIdSelector} from './locatorIdSelector'; -export { - locatorPropertyEndsWithSelector, - locatorPropertyIncludesSelector, - locatorPropertySelector, - locatorPropertyStartsWithSelector, -} from './locatorPropertySelector'; +import {createSelectors} from './createSelectors'; + +export const {createSelector, createSelectorByCss, htmlElementSelector, locatorIdSelector} = + createSelectors({ + getTestAttrName: (property) => (property === 'id' ? 'data-testid' : `data-test-${property}`), + }); + +export {createSelectors}; diff --git a/src/selectors/locatorIdInParentSelector.ts b/src/selectors/locatorIdInParentSelector.ts deleted file mode 100644 index a772885f..00000000 --- a/src/selectors/locatorIdInParentSelector.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type {Selector} from '../types/internal'; - -/** - * Get testId selector in parent. - */ -export const locatorIdInParentSelector = (id: string, parent: Selector): Selector => - parent.find(`[data-testid="${id}"]`); diff --git a/src/selectors/locatorIdSelector.ts b/src/selectors/locatorIdSelector.ts index c9f06474..bb43346e 100644 --- a/src/selectors/locatorIdSelector.ts +++ b/src/selectors/locatorIdSelector.ts @@ -1,9 +1,15 @@ -import {createSelectorByCss} from './createSelectorByCss'; +import type {RawSelector, SelectorCustomMethods} from '../types/internal'; +import type {CreateSelector} from './createSelector'; +import {GetTestAttrName} from '../types/internal'; -import type {Selector} from '../types/internal'; - -/** - * Selector of locator elements (with data-testid attribute) by locator id. - */ -export const locatorIdSelector = (id: string): Selector => - createSelectorByCss(`[data-testid='${id}']`); +export const locatorIdSelectorCreator = ( + createSelector: CreateSelector, + getTestAttrName: GetTestAttrName, +): ((id: string) => RawSelector) => { + /** + * Selector of locator elements by locator id. + */ + return function locatorIdSelector(id) { + return createSelector(`[${getTestAttrName('id')}='${id}']`); + }; +}; diff --git a/src/selectors/locatorPropertySelector.ts b/src/selectors/locatorPropertySelector.ts deleted file mode 100644 index a0632a64..00000000 --- a/src/selectors/locatorPropertySelector.ts +++ /dev/null @@ -1,31 +0,0 @@ -import {createSelectorByCss} from './createSelectorByCss'; - -import type {Selector} from '../types/internal'; - -/** - * Selector of locator elements (with data-test attribute) by locator property and its value. - * Selects elements whose property ends with a value. - */ -export const locatorPropertyEndsWithSelector = (property: string, value: string): Selector => - createSelectorByCss(`[data-test-${property}$='${value}']`); - -/** - * Selector of locator elements (with data-test attribute) by locator property and its value. - * Selects elements whose property includes a value. - */ -export const locatorPropertyIncludesSelector = (property: string, value: string): Selector => - createSelectorByCss(`[data-test-${property}*='${value}']`); - -/** - * Selector of locator elements (with data-test attribute) by locator property and its value. - * Selects elements that have a property that is exactly equal to the value. - */ -export const locatorPropertySelector = (property: string, value: string): Selector => - createSelectorByCss(`[data-test-${property}='${value}']`); - -/** - * Selector of locator elements (with data-test attribute) by locator property and its value. - * Selects elements whose property starts with a value. - */ -export const locatorPropertyStartsWithSelector = (property: string, value: string): Selector => - createSelectorByCss(`[data-test-${property}^='${value}']`); diff --git a/src/types/index.ts b/src/types/index.ts index 2b46ecc5..0a0d33dc 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -36,7 +36,7 @@ export type { RequestHookResponseEvent, } from './requestHooks'; export type {ApiRouteClassType, ApiRouteClassTypeWithGetParamsFromUrl} from './routes'; -export type {Selector} from './selectors'; +export type {Selector, CreateSelectorsOptions} from './selectors'; export type {StackFrame} from './stackTrace'; export type {PackageInfo, StartInfo} from './startInfo'; export type {TestCafeSelector, TestController} from './testCafe'; diff --git a/src/types/internal.ts b/src/types/internal.ts index 4988ee29..682f1657 100644 --- a/src/types/internal.ts +++ b/src/types/internal.ts @@ -98,7 +98,14 @@ export type {ApiRouteClassType, ApiRouteClassTypeWithGetParamsFromUrl} from './r export type {RunLabel, RunLabelObject} from './runLabel'; /** @internal */ export type {RawRunLabelObject} from './runLabel'; -export type {Selector} from './selectors'; +export type { + Selector, + RawSelector, + SelectorCustomMethods, + SelectorDefaultCustomMethods, + CreateSelectorsOptions, + GetTestAttrName, +} from './selectors'; export type {IsTestSkippedResult} from './skipTest'; export type {StackFrame} from './stackTrace'; export type {PackageInfo, StartInfo} from './startInfo'; diff --git a/src/types/selectors.ts b/src/types/selectors.ts index 5632d115..3546d290 100644 --- a/src/types/selectors.ts +++ b/src/types/selectors.ts @@ -2,7 +2,116 @@ import type {Inner} from 'testcafe-without-typecheck'; import type {DESCRIPTION_KEY} from '../constants/internal'; +export type SelectorCustomMethods = Record< + string, + (this: RawSelector, ...args: never[]) => unknown +>; + +export type GetTestAttrName = (property: string) => string; + +export type CreateSelectorsOptions = { + getTestAttrName: GetTestAttrName; +}; + +export type SelectorDefaultCustomMethods = { + /** Creates a selector that filters a matching set by testId. */ + filterByTestId(this: RawSelector, testId: string): RawSelector; + /** Finds all descendants of all nodes in the matching set and filters them by testId. */ + findByTestId(this: RawSelector, testId: string): RawSelector; + /** Finds all parents of all nodes in the matching set and filters them by testId. */ + parentByTestId(this: RawSelector, testId: string): RawSelector; + /** Finds all child elements (not nodes) of all nodes in the matching set and filters them by testId. */ + childByTestId(this: RawSelector, testId: string): RawSelector; + /** Finds all sibling elements (not nodes) of all nodes in the matching set and filters them by testId. */ + siblingByTestId(this: RawSelector, testId: string): RawSelector; + /** Finds all succeeding sibling elements (not nodes) of all nodes in the matching set and filters them by testId. */ + nextSiblingByTestId(this: RawSelector, testId: string): RawSelector; + /** Finds all preceding sibling elements (not nodes) of all nodes in the matching set and filters them by testId. */ + prevSiblingByTestId(this: RawSelector, testId: string): RawSelector; + + /** Creates a selector that filters a matching set by test property. */ + filterByTestProp(this: RawSelector, property: string, value: string): RawSelector; + /** Finds all descendants of all nodes in the matching set and filters them by test property. */ + findByTestProp(this: RawSelector, property: string, value: string): RawSelector; + /** Finds all parents of all nodes in the matching set and filters them by test property. */ + parentByTestProp(this: RawSelector, property: string, value: string): RawSelector; + /** Finds all child elements (not nodes) of all nodes in the matching set and filters them by test property. */ + childByTestProp(this: RawSelector, property: string, value: string): RawSelector; + /** Finds all sibling elements (not nodes) of all nodes in the matching set and filters them by test property. */ + siblingByTestProp(this: RawSelector, property: string, value: string): RawSelector; + /** Finds all succeeding sibling elements (not nodes) of all nodes in the matching set and filters them by test property. */ + nextSiblingByTestProp(this: RawSelector, property: string, value: string): RawSelector; + /** Finds all preceding sibling elements (not nodes) of all nodes in the matching set and filters them by test property. */ + prevSiblingByTestProp(this: RawSelector, property: string, value: string): RawSelector; + + /** Returns the value of the test attribute. */ + getTestProp(this: RawSelector, property: string): Promise; + /** true if the element has the test attribute. */ + hasTestProp(this: RawSelector, property: string): Promise; + + /** Get string description of selector if any. */ + getDescription(this: RawSelector): string | undefined; +}; + +type ReplaceSelector = T extends + | Inner.Selector + | RawSelector + ? RawSelector + : T; + +type ReplaceObjectSelectors< + Obj extends object, + CustomMethods extends SelectorCustomMethods = {}, +> = { + // check overloads, Selector methods has up to 4 + [Key in keyof Obj]: Obj[Key] extends { + (...args: infer A1): infer R1; + (...args: infer A2): infer R2; + (...args: infer A3): infer R3; + (...args: infer A4): infer R4; + } + ? { + (...args: A1): ReplaceSelector; + (...args: A2): ReplaceSelector; + (...args: A3): ReplaceSelector; + (...args: A4): ReplaceSelector; + } + : Obj[Key] extends { + (...args: infer A1): infer R1; + (...args: infer A2): infer R2; + (...args: infer A3): infer R3; + } + ? { + (...args: A1): ReplaceSelector; + (...args: A2): ReplaceSelector; + (...args: A3): ReplaceSelector; + } + : Obj[Key] extends { + (...args: infer A1): infer R1; + (...args: infer A2): infer R2; + } + ? { + (...args: A1): ReplaceSelector; + (...args: A2): ReplaceSelector; + } + : Obj[Key] extends { + (...args: infer A1): infer R1; + } + ? { + (...args: A1): ReplaceSelector; + } + : Obj[Key]; +}; + +export type RawSelector = (( + ...args: any[] +) => Inner.SelectorPromise) & + ReplaceObjectSelectors & + ReplaceObjectSelectors & { + [DESCRIPTION_KEY]?: string; + }; + /** * Selector type (which replaces the DOM element wrapper). */ -export type Selector = Inner.SelectorAPI & {[DESCRIPTION_KEY]?: string}; +export type Selector = RawSelector; diff --git a/src/utils/expect/Expect.ts b/src/utils/expect/Expect.ts index 46cc53a1..341c1b39 100644 --- a/src/utils/expect/Expect.ts +++ b/src/utils/expect/Expect.ts @@ -1,7 +1,6 @@ import {LogEventStatus, LogEventType} from '../../constants/internal'; import {testController} from '../../testController'; -import {getDescriptionFromSelector} from '../locators'; import {log} from '../log'; import {valueToString, wrapStringForLogs} from '../valueToString'; @@ -47,7 +46,7 @@ for (const [key, getAssertionMessage] of Object.entries(assertionMessageGetters) assertion: wrapStringForLogs(`value ${valueToString(actualValue)} ${message}`), assertionArguments: args, error: maybeError, - locator: getDescriptionFromSelector(this.actualValue as Selector), + locator: (this.actualValue as Selector)?.getDescription(), logEventStatus: maybeError ? LogEventStatus.Failed : LogEventStatus.Passed, }, LogEventType.InternalAssert, diff --git a/src/utils/index.ts b/src/utils/index.ts index 6bd371e7..09416cfd 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -30,7 +30,6 @@ export {writeFile} from './fs'; export {removeStyleFromString} from './generalLog'; export {getFullPackConfig as untypedGetFullPackConfig} from './getFullPackConfig'; export {getKeysCounter} from './getKeysCounter'; -export {getDescriptionFromSelector, getLocatorProperty} from './locators'; export {log} from './log'; export {parseMaybeEmptyValueAsJson} from './parseMaybeEmptyValueAsJson'; export {getPromiseWithResolveAndReject, getTimeoutPromise, waitForAllProperties} from './promise'; diff --git a/src/utils/locators/getDescriptionFromSelector.ts b/src/utils/locators/getDescriptionFromSelector.ts deleted file mode 100644 index 68d34e56..00000000 --- a/src/utils/locators/getDescriptionFromSelector.ts +++ /dev/null @@ -1,9 +0,0 @@ -import {DESCRIPTION_KEY} from '../../constants/internal'; - -import type {Selector} from '../../types/internal'; - -/** - * Get string description of selector if any. - */ -export const getDescriptionFromSelector = (selector: Selector): string | undefined => - selector?.[DESCRIPTION_KEY] as string | undefined; diff --git a/src/utils/locators/getLocatorProperty.ts b/src/utils/locators/getLocatorProperty.ts deleted file mode 100644 index 8e7756b6..00000000 --- a/src/utils/locators/getLocatorProperty.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type {Selector} from '../../types/internal'; - -/** - * Get locator property value by property name (value of data-test-* attribute). - */ -export const getLocatorProperty = (selector: Selector, property: string): Promise => - selector.getAttribute(`data-test-${property}`); diff --git a/src/utils/locators/index.ts b/src/utils/locators/index.ts deleted file mode 100644 index a4487121..00000000 --- a/src/utils/locators/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export {getDescriptionFromSelector} from './getDescriptionFromSelector'; -export {getLocatorProperty} from './getLocatorProperty';