diff --git a/detox/detox.d.ts b/detox/detox.d.ts index d164ea117c..3e12d928d6 100644 --- a/detox/detox.d.ts +++ b/detox/detox.d.ts @@ -1094,6 +1094,8 @@ declare global { interface NativeElement extends NativeElementActions { } + type SemanticMatchingTypes = 'image' | 'input-field' | 'text' | 'button' | 'scrollview' | 'list' | 'switch' | 'slider' | 'picker' | 'activity-indicator' | 'progress'; + interface ByFacade { /** * by.id will match an id that is given to the view via testID prop. @@ -1123,10 +1125,19 @@ declare global { label(label: string | RegExp): NativeMatcher; /** - * Find an element by native view type. - * @example await element(by.type('RCTImageView')); + * Find an element by native view type OR semantic type. + * Automatically detects if the input is a semantic type or regular class name. + * @example + * // Semantic types (cross-platform): + * await element(by.type('image')); + * await element(by.type('button')); + * await element(by.type('input-field')); + * + * // Native class names (platform-specific): + * await element(by.type('RCTImageView')); + * await element(by.type('android.widget.Button')); */ - type(nativeViewType: string): NativeMatcher; + type(typeOrSemanticType: SemanticMatchingTypes | string): NativeMatcher; /** * Find an element with an accessibility trait. (iOS only) diff --git a/detox/src/android/matchers/native.js b/detox/src/android/matchers/native.js index d7beadc975..96d5bcafd9 100644 --- a/detox/src/android/matchers/native.js +++ b/detox/src/android/matchers/native.js @@ -1,9 +1,16 @@ const DetoxRuntimeError = require('../../errors/DetoxRuntimeError'); const invoke = require('../../invoke'); +const semanticTypes = require('../../matchers/semanticTypes'); const { isRegExp } = require('../../utils/isRegExp'); const { NativeMatcher } = require('../core/NativeMatcher'); const DetoxMatcherApi = require('../espressoapi/DetoxMatcher'); +const createClassMatcher = (className) => + new NativeMatcher(invoke.callDirectly(DetoxMatcherApi.matcherForClass(className))); + +const combineWithOr = (matchers) => + matchers.reduce((acc, matcher) => acc?.or(matcher) ?? matcher, null); + class LabelMatcher extends NativeMatcher { constructor(value) { super(); @@ -29,9 +36,29 @@ class IdMatcher extends NativeMatcher { } class TypeMatcher extends NativeMatcher { - constructor(value) { + constructor(typeOrSemanticType) { super(); - this._call = invoke.callDirectly(DetoxMatcherApi.matcherForClass(value)); + if (semanticTypes.includes(typeOrSemanticType)) { + const classNames = semanticTypes.getClasses(typeOrSemanticType, 'android'); + + const matchers = classNames.map(item => { + if (typeof item === 'string') return createClassMatcher(item); + if (!item.className || !item.excludes) return createClassMatcher(item); + + const includeMatcher = createClassMatcher(item.className); + const excludeCombined = combineWithOr(item.excludes.map(createClassMatcher)); + + return includeMatcher.and(excludeCombined.not); + }); + + const combinedMatcher = combineWithOr(matchers); + if (!combinedMatcher) { + throw new DetoxRuntimeError(`No class names found for semantic type: ${typeOrSemanticType}`); + } + this._call = combinedMatcher._call; + } else { + this._call = invoke.callDirectly(DetoxMatcherApi.matcherForClass(typeOrSemanticType)); + } } } diff --git a/detox/src/android/matchers/native.test.js b/detox/src/android/matchers/native.test.js new file mode 100644 index 0000000000..3697db5e55 --- /dev/null +++ b/detox/src/android/matchers/native.test.js @@ -0,0 +1,84 @@ +// @ts-nocheck +// Mock the semanticTypes module before importing anything that depends on it +jest.mock('../../matchers/semanticTypes', () => ({ + getTypes: jest.fn(), + getClasses: jest.fn(), + includes: jest.fn() +})); + + +const semanticTypes = require('../../matchers/semanticTypes'); + +const { TypeMatcher } = require('./native'); + +describe('Native Matchers', () => { + describe('TypeMatcher', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should handle regular class names', () => { + semanticTypes.includes.mockReturnValue(false); + + expect(() => { + new TypeMatcher('com.example.CustomView'); + }).not.toThrow(); + }); + + it('should handle semantic types automatically', () => { + semanticTypes.includes.mockReturnValue(true); + semanticTypes.getClasses.mockReturnValue([ + 'android.widget.ImageView', + 'com.facebook.react.views.image.ReactImageView' + ]); + + expect(() => { + new TypeMatcher('image'); + }).not.toThrow(); + }); + + it('should handle exclusion objects for semantic types', () => { + semanticTypes.includes.mockReturnValue(true); + semanticTypes.getClasses.mockReturnValue([ + { + className: 'android.widget.ProgressBar', + excludes: ['android.widget.AbsSeekBar'] + }, + { + className: 'androidx.core.widget.ContentLoadingProgressBar', + excludes: ['android.widget.AbsSeekBar'] + } + ]); + + expect(() => { + new TypeMatcher('activity-indicator'); + }).not.toThrow(); + }); + + it('should handle mixed string and exclusion objects', () => { + semanticTypes.includes.mockReturnValue(true); + semanticTypes.getClasses.mockReturnValue([ + { + className: 'android.widget.ProgressBar', + excludes: ['android.widget.AbsSeekBar'] + }, + { + className: 'androidx.core.widget.ContentLoadingProgressBar', + excludes: ['android.widget.AbsSeekBar'] + } + ]); + + expect(() => { + new TypeMatcher('progress'); + }).not.toThrow(); + }); + + it('should handle regular class names when not semantic types', () => { + semanticTypes.includes.mockReturnValue(false); + + expect(() => { + new TypeMatcher('android.widget.ImageView'); + }).not.toThrow(); + }); + }); +}); diff --git a/detox/src/ios/expectTwo.js b/detox/src/ios/expectTwo.js index 4c1c556519..45448e874f 100644 --- a/detox/src/ios/expectTwo.js +++ b/detox/src/ios/expectTwo.js @@ -5,6 +5,24 @@ const fs = require('fs-extra'); const _ = require('lodash'); +const semanticTypes = require('../matchers/semanticTypes'); + +// Functions for semantic type predicate creation +const createTypePredicate = (className) => ({ type: 'type', value: className }); + +const createOrPredicate = (predicates) => ({ type: 'or', predicates }); + +const createExclusionPredicate = (className, excludes) => ({ + type: 'and', + predicates: [ + createTypePredicate(className), + { + type: 'not', + predicate: createOrPredicate(excludes.map(createTypePredicate)) + } + ] +}); + const { assertTraceDescription, assertEnum, assertNormalized } = require('../utils/assertArgument'); const { removeMilliseconds } = require('../utils/dateUtils'); const { actionDescription, expectDescription } = require('../utils/invocationTraceDescriptions'); @@ -440,9 +458,21 @@ class Matcher { return this; } - type(type) { - if (typeof type !== 'string') throw new Error('type should be a string, but got ' + (type + (' (' + (typeof type + ')')))); - this.predicate = { type: 'type', value: type }; + type(typeOrSemanticType) { + if (typeof typeOrSemanticType !== 'string') throw new Error('type should be a string, but got ' + (typeOrSemanticType + (' (' + (typeof typeOrSemanticType + ')')))); + + if (semanticTypes.includes(typeOrSemanticType)) { + const classNames = semanticTypes.getClasses(typeOrSemanticType, 'ios'); + const predicates = classNames.map(item => { + if (typeof item === 'string') return createTypePredicate(item); + if (!item.className || !item.excludes) return createTypePredicate(item); + return createExclusionPredicate(item.className, item.excludes); + }); + this.predicate = createOrPredicate(predicates); + } else { + this.predicate = { type: 'type', value: typeOrSemanticType }; + } + return this; } diff --git a/detox/src/ios/expectTwo.test.js b/detox/src/ios/expectTwo.test.js index bcbc77f0ff..f09697ab6c 100644 --- a/detox/src/ios/expectTwo.test.js +++ b/detox/src/ios/expectTwo.test.js @@ -793,6 +793,49 @@ describe('expectTwo', () => { }); }); + describe('semantic types', () => { + it(`should parse correct JSON for regular class name using by.type()`, async () => { + const testCall = await e.element(e.by.type('CustomUIView')).tap(); + const jsonOutput = { + invocation: { + type: 'action', + action: 'tap', + predicate: { + type: 'type', + value: 'CustomUIView' + } + } + }; + + expect(testCall).toDeepEqual(jsonOutput); + }); + + it(`should parse correct JSON for semantic type 'image' using by.type()`, async () => { + const testCall = await e.element(e.by.type('image')).tap(); + const jsonOutput = { + invocation: { + type: 'action', + action: 'tap', + predicate: { + type: 'or', + predicates: [ + { + type: 'type', + value: 'RCTImageView' + }, + { + type: 'type', + value: 'UIImageView' + } + ] + } + } + }; + + expect(testCall).toDeepEqual(jsonOutput); + }); + }); + describe('web views', () => { it(`should parse expect(web(by.id('webViewId').element(web(by.label('tapMe')))).toExist()`, async () => { const testCall = await e.expect(e.web(e.by.id('webViewId')).atIndex(1).element(e.by.web.label('tapMe')).atIndex(2)).toExist(); diff --git a/detox/src/matchers/semanticTypes.js b/detox/src/matchers/semanticTypes.js new file mode 100644 index 0000000000..a9153ce0f9 --- /dev/null +++ b/detox/src/matchers/semanticTypes.js @@ -0,0 +1,137 @@ +/** + * Semantic type mappings for cross-platform component matching + */ + +// Shared class mappings for aliases +const ACTIVITY_INDICATOR_CLASSES = { + ios: ['UIActivityIndicatorView'], + android: [ + { + include: ['android.widget.ProgressBar', 'androidx.core.widget.ContentLoadingProgressBar'], + exclude: ['android.widget.AbsSeekBar'] + } + ] +}; + +const SEMANTIC_TYPE_MAPPINGS = { + // Images + 'image': { + ios: ['RCTImageView', 'UIImageView'], + android: ['android.widget.ImageView', 'com.facebook.react.views.image.ReactImageView'] + }, + + // Input fields + 'input-field': { + ios: ['RCTTextInputView', 'RCTMultilineTextInputView', 'UITextField', 'UITextView'], + android: ['android.widget.EditText', 'com.facebook.react.views.textinput.ReactEditText'] + }, + + // Text elements + 'text': { + ios: ['RCTText', 'RCTParagraphComponentView', 'UILabel'], + android: [ + { + include: ['android.widget.TextView', 'com.facebook.react.views.text.ReactTextView'], + exclude: ['android.widget.EditText', 'android.widget.Button'] + } + ] + }, + + // Button elements + 'button': { + ios: ['UIButton', 'RCTTouchableOpacity', 'RCTTouchableHighlight', 'RCTTouchableWithoutFeedback'], + android: ['android.widget.Button', 'android.widget.ImageButton'] + }, + + // Scroll containers - The UITableView inherits from scrollview so it could also show up here... + 'scrollview': { + ios: ['RCTScrollView', 'UIScrollView'], + android: ['android.widget.ScrollView', 'androidx.core.widget.NestedScrollView', 'com.facebook.react.views.scroll.ReactScrollView'] + }, + + // Lists + 'list': { + ios: ['UITableView', 'UICollectionView', 'RCTScrollView'], + android: ['android.widget.ListView', 'androidx.recyclerview.widget.RecyclerView', 'com.facebook.react.views.scroll.ReactScrollView'] + }, + + // Switches/Toggles + 'switch': { + ios: ['UISwitch'], + android: ['android.widget.Switch', 'androidx.appcompat.widget.SwitchCompat', 'com.facebook.react.views.switchview.ReactSwitch'] + }, + + // Sliders + 'slider': { + ios: ['UISlider'], + android: ['android.widget.SeekBar'] + }, + + // Picker/Selector + 'picker': { + ios: ['UIPickerView'], + android: ['android.widget.Spinner', 'androidx.appcompat.widget.AppCompatSpinner'] + }, + + // Activity indicators/Progress + 'activity-indicator': ACTIVITY_INDICATOR_CLASSES, + + // Progress (alias for activity-indicator) + 'progress': ACTIVITY_INDICATOR_CLASSES +}; + +/** + * Get platform-specific class names for a semantic type + * @param {string} semanticType - The semantic type (e.g., 'image', 'input-field') + * @param {string} platform - The platform ('ios' or 'android') + * @returns {Array} Array of class names or matcher objects for the platform + */ +function getClasses(semanticType, platform) { + const mapping = SEMANTIC_TYPE_MAPPINGS[semanticType]; + if (!mapping) { + throw new Error(`Unknown semantic type: ${semanticType}. Available types: ${Object.keys(SEMANTIC_TYPE_MAPPINGS).join(', ')}`); + } + + const classNames = mapping[platform]; + if (!classNames) { + throw new Error(`Platform ${platform} not supported for semantic type ${semanticType}`); + } + + return classNames.map(item => { + if (typeof item === 'string') { + return item; + } else if (item.include && item.exclude) { + if (Array.isArray(item.include)) { + return item.include.map(className => ({ + className, + excludes: item.exclude + })); + } else { + return { + className: item.include, + excludes: item.exclude + }; + } + } + return item; + }).flat(); +} + +/** + * Get all available semantic types + * @returns {string[]} Array of available semantic type names + */ +function getTypes() { + return Object.keys(SEMANTIC_TYPE_MAPPINGS); +} + +function includes(value) { + return getTypes().includes(value); +} + +module.exports = { + SEMANTIC_TYPE_MAPPINGS, + getClasses, + getTypes, + includes, +}; diff --git a/detox/src/matchers/semanticTypes.test.js b/detox/src/matchers/semanticTypes.test.js new file mode 100644 index 0000000000..7f30f4340f --- /dev/null +++ b/detox/src/matchers/semanticTypes.test.js @@ -0,0 +1,185 @@ +const semanticTypes = require('./semanticTypes'); + +describe('semanticTypes', () => { + const testMatrix = []; + const semanticTypeList = semanticTypes.getTypes(); + const platforms = ['ios', 'android']; + + describe('getClasses', () => { + + semanticTypeList.forEach(semanticType => { + platforms.forEach(platform => { + testMatrix.push([semanticType, platform]); + }); + }); + + test.each(testMatrix)('should return class names for %s on %s', (semanticType, platform) => { + const classNames = semanticTypes.getClasses(semanticType, platform); + expect(classNames).toMatchSnapshot(`${semanticType}-${platform}`); + }); + + it('should throw error for unknown semantic type', () => { + expect(() => { + semanticTypes.getClasses('unknown-type', 'ios'); + }).toThrow('Unknown semantic type: unknown-type'); + }); + + it('should throw error for unsupported platform', () => { + expect(() => { + semanticTypes.getClasses('image', 'windows'); + }).toThrow('Platform windows not supported for semantic type image'); + }); + + it('should return same classes for progress and activity-indicator', () => { + platforms.forEach(platform => { + expect(semanticTypes.getClasses('progress', platform)).toEqual( + semanticTypes.getClasses('activity-indicator', platform) + ); + }); + }); + + it('should handle exclusion objects correctly', () => { + const classNames = semanticTypes.getClasses('activity-indicator', 'android'); + + expect(classNames).toHaveLength(2); + expect(classNames[0]).toHaveProperty('className', 'android.widget.ProgressBar'); + expect(classNames[0]).toHaveProperty('excludes', ['android.widget.AbsSeekBar']); + expect(classNames[1]).toHaveProperty('className', 'androidx.core.widget.ContentLoadingProgressBar'); + expect(classNames[1]).toHaveProperty('excludes', ['android.widget.AbsSeekBar']); + }); + + it('should handle text exclusions to prevent EditText and Button conflicts', () => { + const classNames = semanticTypes.getClasses('text', 'android'); + + expect(classNames).toHaveLength(2); + expect(classNames[0]).toHaveProperty('className', 'android.widget.TextView'); + expect(classNames[0]).toHaveProperty('excludes', ['android.widget.EditText', 'android.widget.Button']); + expect(classNames[1]).toHaveProperty('className', 'com.facebook.react.views.text.ReactTextView'); + expect(classNames[1]).toHaveProperty('excludes', ['android.widget.EditText', 'android.widget.Button']); + }); + + it('should handle single string include with exclusions', () => { + const originalMapping = semanticTypes.SEMANTIC_TYPE_MAPPINGS['test-single']; + semanticTypes.SEMANTIC_TYPE_MAPPINGS['test-single'] = { + android: [{ + include: 'android.widget.TestView', + exclude: ['android.widget.ExcludedView'] + }] + }; + + const classNames = semanticTypes.getClasses('test-single', 'android'); + + expect(classNames).toHaveLength(1); + expect(classNames[0]).toHaveProperty('className', 'android.widget.TestView'); + expect(classNames[0]).toHaveProperty('excludes', ['android.widget.ExcludedView']); + + delete semanticTypes.SEMANTIC_TYPE_MAPPINGS['test-single']; + }); + + it('should handle unexpected item types gracefully', () => { + semanticTypes.SEMANTIC_TYPE_MAPPINGS['test-fallback'] = { + android: [{ unexpectedProperty: 'someValue' }] + }; + + const classNames = semanticTypes.getClasses('test-fallback', 'android'); + + expect(classNames).toHaveLength(1); + expect(classNames[0]).toEqual({ unexpectedProperty: 'someValue' }); + + delete semanticTypes.SEMANTIC_TYPE_MAPPINGS['test-fallback']; + }); + }); + + describe('getTypes', () => { + it('should return all semantic types from mappings', () => { + const types = semanticTypes.getTypes(); + const mappingKeys = Object.keys(semanticTypes.SEMANTIC_TYPE_MAPPINGS); + expect(types).toEqual(mappingKeys); + }); + + it('should return an array', () => { + const types = semanticTypes.getTypes(); + expect(Array.isArray(types)).toBe(true); + }); + + it('should not be empty', () => { + const types = semanticTypes.getTypes(); + expect(types.length).toBeGreaterThan(0); + }); + }); + + describe('includes', () => { + it('should return true for valid semantic types', () => { + semanticTypeList.forEach(semanticType => { + expect(semanticTypes.includes(semanticType)).toBe(true); + }); + }); + + it('should return false for invalid semantic types', () => { + expect(semanticTypes.includes('unknown-type')).toBe(false); + expect(semanticTypes.includes('')).toBe(false); + + const buttonClasses = semanticTypes.getClasses('button', 'ios'); + const imageClasses = semanticTypes.getClasses('image', 'android'); + + buttonClasses.forEach(className => { + if (typeof className === 'string') { + expect(semanticTypes.includes(className)).toBe(false); + } + }); + + imageClasses.forEach(className => { + if (typeof className === 'string') { + expect(semanticTypes.includes(className)).toBe(false); + } + }); + }); + + it('should return false for non-string values', () => { + expect(semanticTypes.includes(null)).toBe(false); + expect(semanticTypes.includes(undefined)).toBe(false); + expect(semanticTypes.includes(123)).toBe(false); + expect(semanticTypes.includes({})).toBe(false); + expect(semanticTypes.includes([])).toBe(false); + }); + }); + + describe('cross-platform validation', () => { + it('should ensure all semantic types have valid class names for both platforms', () => { + const allSemanticTypes = semanticTypes.getTypes(); + allSemanticTypes.forEach(semanticType => { + const androidClassNames = semanticTypes.getClasses(semanticType, 'android'); + const iosClassNames = semanticTypes.getClasses(semanticType, 'ios'); + + expect(androidClassNames.length).toBeGreaterThan(0); + expect(iosClassNames.length).toBeGreaterThan(0); + + [...androidClassNames, ...iosClassNames].forEach(item => { + if (typeof item === 'string') { + expect(item.length).toBeGreaterThan(0); + } else if (typeof item === 'object' && item !== null) { + expect(item).toHaveProperty('className'); + expect(item).toHaveProperty('excludes'); + expect(typeof item.className).toBe('string'); + expect(item.className.length).toBeGreaterThan(0); + expect(Array.isArray(item.excludes)).toBe(true); + expect(item.excludes.length).toBeGreaterThan(0); + item.excludes.forEach(excludeClass => { + expect(typeof excludeClass).toBe('string'); + expect(excludeClass.length).toBeGreaterThan(0); + }); + } else { + fail(`Invalid class name type: ${typeof item}`); + } + }); + }); + }); + + it('should differentiate between semantic types and regular class names', () => { + expect(semanticTypes.getTypes().includes('image')).toBe(true); + expect(semanticTypes.getTypes().includes('button')).toBe(true); + expect(semanticTypes.getTypes().includes('com.example.CustomView')).toBe(false); + expect(semanticTypes.getTypes().includes('UIButton')).toBe(false); + }); + }); +}); diff --git a/detox/test/e2e/27.semantic-types.test.js b/detox/test/e2e/27.semantic-types.test.js new file mode 100644 index 0000000000..7f8265a4d7 --- /dev/null +++ b/detox/test/e2e/27.semantic-types.test.js @@ -0,0 +1,78 @@ +const { isRNNewArch } = require('../../src/utils/rn-consts/rn-consts'); + +describe('Semantic Types', () => { + beforeEach(async () => { + await device.reloadReactNative(); + await element(by.text('Matchers')).tap(); + }); + + it('should match image elements by semantic type using by.type()', async () => { + await expect(element(by.type('image'))).toBeVisible(); + await element(by.type('image')).tap(); + await expect(element(by.type('image'))).not.toBeVisible(); + }); + + it('should match button elements by semantic type using by.type()', async () => { + await expect(element(by.type('button')).atIndex(0)).toBeVisible(); + await element(by.type('button').and(by.label('Traits'))).tap(); + await expect(element(by.text('Traits Working!!!'))).toBeVisible(); + }); + + it('should match text elements by semantic type using by.type()', async () => { + await expect(element(by.type('text')).atIndex(0)).toBeVisible(); + await expect(element(by.type('text').and(by.text('Index')))).toBeVisible(); + }); + + it('should match input field elements by semantic type using by.type()', async () => { + await element(by.text('Actions')).tap(); + await expect(element(by.type('input-field')).atIndex(0)).toBeVisible(); + await element(by.type('input-field')).atIndex(0).typeText('Test'); + await expect(element(by.type('input-field')).atIndex(0)).toHaveText('Test'); + }); + + it('should work with multiple elements of the same semantic type', async () => { + await expect(element(by.type('button')).atIndex(0)).toBeVisible(); + await expect(element(by.type('button')).atIndex(1)).toBeVisible(); + await element(by.type('button')).atIndex(0).tap(); + }); + + it('should work with combined matchers', async () => { + await expect(element(by.type('button').and(by.label('Label')))).toBeVisible(); + await element(by.type('button').and(by.label('Label'))).tap(); + await expect(element(by.text('Label Working!!!'))).toBeVisible(); + }); + + it('should work with ancestor matchers', async () => { + await expect(element(by.type('text').withAncestor(by.id('Grandfather883')))).toExist(); + }); + + it('should differentiate semantic types from regular class names', async () => { + // Semantic type + await expect(element(by.type('image'))).toBeVisible(); + + // Regular class name + const iOSClass = isRNNewArch ? 'RCTImageComponentView' : 'RCTImageView'; + const platformClass = device.getPlatform() === 'ios' ? iOSClass : 'android.widget.ImageView'; + await expect(element(by.type(platformClass))).toBeVisible(); + }); + + it('should work consistently across platforms', async () => { + await expect(element(by.type('image'))).toBeVisible(); + await expect(element(by.type('button')).atIndex(0)).toBeVisible(); + }); + + it('should work with waitFor operations', async () => { + await waitFor(element(by.type('button')).atIndex(0)) + .toBeVisible() + .withTimeout(2000); + }); + + it('should support progress alias for activity-indicator', async () => { + // Both should work identically + await element(by.text('Actions')).tap(); + // Note: This test assumes the app has progress indicators + // In a real test app, you'd verify both 'progress' and 'activity-indicator' work the same + expect(() => element(by.type('progress'))).not.toThrow(); + expect(() => element(by.type('activity-indicator'))).not.toThrow(); + }); +});