diff --git a/examples/basic/package.json b/examples/basic/package.json index 48e7237d..86dd9d99 100644 --- a/examples/basic/package.json +++ b/examples/basic/package.json @@ -20,7 +20,7 @@ }, "devDependencies": { "@babel/core": "^7.24.0", - "@testing-library/react-native": "^12.5.2", + "@testing-library/react-native": "^12.7.1", "@types/eslint": "^8.56.10", "@types/jest": "^29.5.12", "@types/react": "~18.2.79", diff --git a/examples/basic/yarn.lock b/examples/basic/yarn.lock index 93e880e4..69368e8e 100644 --- a/examples/basic/yarn.lock +++ b/examples/basic/yarn.lock @@ -2463,9 +2463,9 @@ __metadata: languageName: node linkType: hard -"@testing-library/react-native@npm:^12.5.2": - version: 12.5.3 - resolution: "@testing-library/react-native@npm:12.5.3" +"@testing-library/react-native@npm:^12.7.1": + version: 12.7.1 + resolution: "@testing-library/react-native@npm:12.7.1" dependencies: jest-matcher-utils: "npm:^29.7.0" pretty-format: "npm:^29.7.0" @@ -2478,7 +2478,7 @@ __metadata: peerDependenciesMeta: jest: optional: true - checksum: 10c0/6e404bfee5677dfa54405f30e91a24fccb50cc6cf98d0e296ae4ebccb71641a1a1dcefc2ffaa9c3c05eacf2b39c99431f6099ed0227696de4ec914768aa70279 + checksum: 10c0/caaa4bdf97834b307b72af05c447ce40a2ba2ff40b464050bc29535caadf81981ea2873668445e633fdb3d13efccb136ef0932d6d9f4736bc6f7f98be98088d4 languageName: node linkType: hard @@ -8923,7 +8923,7 @@ __metadata: resolution: "root-workspace-0b6124@workspace:." dependencies: "@babel/core": "npm:^7.24.0" - "@testing-library/react-native": "npm:^12.5.2" + "@testing-library/react-native": "npm:^12.7.1" "@types/eslint": "npm:^8.56.10" "@types/jest": "npm:^29.5.12" "@types/react": "npm:~18.2.79" diff --git a/examples/cookbook/package.json b/examples/cookbook/package.json index 64f440c3..2468698c 100644 --- a/examples/cookbook/package.json +++ b/examples/cookbook/package.json @@ -30,7 +30,7 @@ "devDependencies": { "@babel/core": "^7.20.0", "@expo/metro-runtime": "~3.2.1", - "@testing-library/react-native": "^12.5.2", + "@testing-library/react-native": "^12.7.1", "@types/eslint": "^8.56.10", "@types/jest": "^29.5.12", "@types/react": "~18.2.45", diff --git a/examples/cookbook/yarn.lock b/examples/cookbook/yarn.lock index 99220059..66fef55c 100644 --- a/examples/cookbook/yarn.lock +++ b/examples/cookbook/yarn.lock @@ -2720,9 +2720,9 @@ __metadata: languageName: node linkType: hard -"@testing-library/react-native@npm:^12.5.2": - version: 12.5.3 - resolution: "@testing-library/react-native@npm:12.5.3" +"@testing-library/react-native@npm:^12.7.1": + version: 12.7.1 + resolution: "@testing-library/react-native@npm:12.7.1" dependencies: jest-matcher-utils: "npm:^29.7.0" pretty-format: "npm:^29.7.0" @@ -2735,7 +2735,7 @@ __metadata: peerDependenciesMeta: jest: optional: true - checksum: 10c0/6e404bfee5677dfa54405f30e91a24fccb50cc6cf98d0e296ae4ebccb71641a1a1dcefc2ffaa9c3c05eacf2b39c99431f6099ed0227696de4ec914768aa70279 + checksum: 10c0/caaa4bdf97834b307b72af05c447ce40a2ba2ff40b464050bc29535caadf81981ea2873668445e633fdb3d13efccb136ef0932d6d9f4736bc6f7f98be98088d4 languageName: node linkType: hard @@ -9499,7 +9499,7 @@ __metadata: dependencies: "@babel/core": "npm:^7.20.0" "@expo/metro-runtime": "npm:~3.2.1" - "@testing-library/react-native": "npm:^12.5.2" + "@testing-library/react-native": "npm:^12.7.1" "@types/eslint": "npm:^8.56.10" "@types/jest": "npm:^29.5.12" "@types/react": "npm:~18.2.45" diff --git a/package.json b/package.json index c888a4a1..43fe5f35 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@testing-library/react-native", - "version": "12.6.1", + "version": "12.7.1", "description": "Simple and complete React Native testing utilities that encourage good testing practices.", "main": "build/index.js", "types": "build/index.d.ts", @@ -33,8 +33,7 @@ "build:js": "babel src --out-dir build --extensions \".js,.ts,.jsx,.tsx\" --source-maps --ignore \"**/__tests__/**\"", "build:ts": "tsc --build tsconfig.release.json", "build": "yarn clean && yarn build:js && yarn build:ts && yarn copy-flowtypes", - "prepublish": "yarn build", - "publish": "release-it" + "publish": "yarn build && release-it" }, "files": [ "build/", diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index 47cdc662..aca62f30 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -33,6 +33,7 @@ test('resetToDefaults() resets internal config to defaults', () => { hostComponentNames: { text: 'A', textInput: 'A', + image: 'A', switch: 'A', scrollView: 'A', modal: 'A', @@ -41,6 +42,7 @@ test('resetToDefaults() resets internal config to defaults', () => { expect(getConfig().hostComponentNames).toEqual({ text: 'A', textInput: 'A', + image: 'A', switch: 'A', scrollView: 'A', modal: 'A', diff --git a/src/__tests__/host-component-names.test.tsx b/src/__tests__/host-component-names.test.tsx index ca526742..0e55f1a8 100644 --- a/src/__tests__/host-component-names.test.tsx +++ b/src/__tests__/host-component-names.test.tsx @@ -14,6 +14,7 @@ describe('getHostComponentNames', () => { hostComponentNames: { text: 'banana', textInput: 'banana', + image: 'banana', switch: 'banana', scrollView: 'banana', modal: 'banana', @@ -23,6 +24,7 @@ describe('getHostComponentNames', () => { expect(getHostComponentNames()).toEqual({ text: 'banana', textInput: 'banana', + image: 'banana', switch: 'banana', scrollView: 'banana', modal: 'banana', @@ -37,6 +39,7 @@ describe('getHostComponentNames', () => { expect(hostComponentNames).toEqual({ text: 'Text', textInput: 'TextInput', + image: 'Image', switch: 'RCTSwitch', scrollView: 'RCTScrollView', modal: 'Modal', @@ -67,6 +70,7 @@ describe('configureHostComponentNamesIfNeeded', () => { expect(getConfig().hostComponentNames).toEqual({ text: 'Text', textInput: 'TextInput', + image: 'Image', switch: 'RCTSwitch', scrollView: 'RCTScrollView', modal: 'Modal', @@ -78,6 +82,7 @@ describe('configureHostComponentNamesIfNeeded', () => { hostComponentNames: { text: 'banana', textInput: 'banana', + image: 'banana', switch: 'banana', scrollView: 'banana', modal: 'banana', @@ -89,13 +94,14 @@ describe('configureHostComponentNamesIfNeeded', () => { expect(getConfig().hostComponentNames).toEqual({ text: 'banana', textInput: 'banana', + image: 'banana', switch: 'banana', scrollView: 'banana', modal: 'banana', }); }); - test('throw an error when autodetection fails', () => { + test('throw an error when auto-detection fails', () => { const mockCreate = jest.spyOn(TestRenderer, 'create') as jest.Mock; const renderer = TestRenderer.create(); diff --git a/src/__tests__/react-native-api.test.tsx b/src/__tests__/react-native-api.test.tsx index 7393fd4b..3ce5fc5f 100644 --- a/src/__tests__/react-native-api.test.tsx +++ b/src/__tests__/react-native-api.test.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { FlatList, ScrollView, Switch, Text, TextInput, View } from 'react-native'; +import { FlatList, Image, Modal, ScrollView, Switch, Text, TextInput, View } from 'react-native'; import { render, screen } from '..'; /** @@ -7,7 +7,7 @@ import { render, screen } from '..'; * changed in a way that may impact our code like queries or event handling. */ -test('React Native API assumption: renders single host element', () => { +test('React Native API assumption: renders a single host element', () => { render(); expect(screen.toJSON()).toMatchInlineSnapshot(` @@ -17,7 +17,7 @@ test('React Native API assumption: renders single host element', () => { `); }); -test('React Native API assumption: renders single host element', () => { +test('React Native API assumption: renders a single host element', () => { render(Hello); expect(screen.toJSON()).toMatchInlineSnapshot(` @@ -29,7 +29,7 @@ test('React Native API assumption: renders single host element', () => { `); }); -test('React Native API assumption: nested renders single host element', () => { +test('React Native API assumption: nested renders a single host element', () => { render( Before @@ -63,7 +63,7 @@ test('React Native API assumption: nested renders single host element', ( `); }); -test('React Native API assumption: renders single host element', () => { +test('React Native API assumption: renders a single host element', () => { render( with nested Text renders single h `); }); -test('React Native API assumption: renders single host element', () => { +test('React Native API assumption: renders a single host element', () => { render(); expect(screen.toJSON()).toMatchInlineSnapshot(` @@ -123,151 +123,23 @@ test('React Native API assumption: renders single host element', () => `); }); -test('React Native API assumption: aria-* props render on host View', () => { - render( - , - ); +test('React Native API assumption: renders a single host element', () => { + render(Alt text); expect(screen.toJSON()).toMatchInlineSnapshot(` - `); }); -test('React Native API assumption: aria-* props render on host Text', () => { - render( - , - ); - - expect(screen.toJSON()).toMatchInlineSnapshot(` - - `); -}); - -test('React Native API assumption: aria-* props render on host TextInput', () => { - render( - , - ); - - expect(screen.toJSON()).toMatchInlineSnapshot(` - - `); -}); - -test('ScrollView renders correctly', () => { +test('React Native API assumption: renders a single host element', () => { render( @@ -287,7 +159,7 @@ test('ScrollView renders correctly', () => { `); }); -test('FlatList renders correctly', () => { +test('React Native API assumption: renders a single host element', () => { render( {item}} />, ); @@ -340,3 +212,167 @@ test('FlatList renders correctly', () => { `); }); + +test('React Native API assumption: renders a single host element', () => { + render( + + Modal Content + , + ); + + expect(screen.toJSON()).toMatchInlineSnapshot(` + + + Modal Content + + + `); +}); + +test('React Native API assumption: aria-* props render directly on host View', () => { + render( + , + ); + + expect(screen.toJSON()).toMatchInlineSnapshot(` + + `); +}); + +test('React Native API assumption: aria-* props render directly on host Text', () => { + render( + , + ); + + expect(screen.toJSON()).toMatchInlineSnapshot(` + + `); +}); + +test('React Native API assumption: aria-* props render directly on host TextInput', () => { + render( + , + ); + + expect(screen.toJSON()).toMatchInlineSnapshot(` + + `); +}); diff --git a/src/cleanup.ts b/src/cleanup.ts index c7ee13cb..880bc2ee 100644 --- a/src/cleanup.ts +++ b/src/cleanup.ts @@ -1,4 +1,3 @@ -import { clearNativeState } from './native-state'; import { clearRenderResult } from './screen'; type CleanUpFunction = () => void; @@ -6,7 +5,6 @@ type CleanUpFunction = () => void; const cleanupQueue = new Set(); export default function cleanup() { - clearNativeState(); clearRenderResult(); cleanupQueue.forEach((fn) => fn()); diff --git a/src/config.ts b/src/config.ts index 78e63977..6e101dfb 100644 --- a/src/config.ts +++ b/src/config.ts @@ -23,6 +23,7 @@ export type ConfigAliasOptions = { export type HostComponentNames = { text: string; textInput: string; + image: string; switch: string; scrollView: string; modal: string; diff --git a/src/fire-event.ts b/src/fire-event.ts index 849c01ee..ac7d8802 100644 --- a/src/fire-event.ts +++ b/src/fire-event.ts @@ -8,10 +8,10 @@ import { } from 'react-native'; import act from './act'; import { isHostElement } from './helpers/component-tree'; -import { isHostTextInput } from './helpers/host-component-names'; +import { isHostScrollView, isHostTextInput } from './helpers/host-component-names'; import { isPointerEventEnabled } from './helpers/pointer-events'; import { isTextInputEditable } from './helpers/text-input'; -import { StringWithAutocomplete } from './types'; +import { Point, StringWithAutocomplete } from './types'; import { nativeState } from './native-state'; type EventHandler = (...args: unknown[]) => unknown; @@ -147,6 +147,14 @@ fireEvent.scroll = (element: ReactTestInstance, ...data: unknown[]) => export default fireEvent; +const scrollEventNames = new Set([ + 'scroll', + 'scrollBeginDrag', + 'scrollEndDrag', + 'momentumScrollBegin', + 'momentumScrollEnd', +]); + function setNativeStateIfNeeded(element: ReactTestInstance, eventName: string, value: unknown) { if ( eventName === 'changeText' && @@ -154,6 +162,32 @@ function setNativeStateIfNeeded(element: ReactTestInstance, eventName: string, v isHostTextInput(element) && isTextInputEditable(element) ) { - nativeState?.elementValues.set(element, value); + nativeState.valueForElement.set(element, value); + } + + if (scrollEventNames.has(eventName) && isHostScrollView(element)) { + const contentOffset = tryGetContentOffset(value); + if (contentOffset) { + nativeState.contentOffsetForElement.set(element, contentOffset); + } } } + +function tryGetContentOffset(value: unknown): Point | null { + try { + // @ts-expect-error: try to extract contentOffset from the event value + const contentOffset = value?.nativeEvent?.contentOffset; + const x = contentOffset?.x; + const y = contentOffset?.y; + if (typeof x === 'number' || typeof y === 'number') { + return { + x: Number.isFinite(x) ? x : 0, + y: Number.isFinite(y) ? y : 0, + }; + } + } catch { + // Do nothing + } + + return null; +} diff --git a/src/helpers/accessibility.ts b/src/helpers/accessibility.ts index 141d45ea..5eee9401 100644 --- a/src/helpers/accessibility.ts +++ b/src/helpers/accessibility.ts @@ -7,7 +7,13 @@ import { } from 'react-native'; import { ReactTestInstance } from 'react-test-renderer'; import { getHostSiblings, getUnsafeRootElement } from './component-tree'; -import { getHostComponentNames, isHostText, isHostTextInput } from './host-component-names'; +import { + getHostComponentNames, + isHostImage, + isHostSwitch, + isHostText, + isHostTextInput, +} from './host-component-names'; import { getTextContent } from './text-content'; import { isTextInputEditable } from './text-input'; @@ -97,6 +103,11 @@ export function isAccessibilityElement(element: ReactTestInstance | null): boole return false; } + // https://github.com/facebook/react-native/blob/8dabed60f456e76a9e53273b601446f34de41fb5/packages/react-native/Libraries/Image/Image.ios.js#L172 + if (isHostImage(element) && element.props.alt !== undefined) { + return true; + } + if (element.props.accessible !== undefined) { return element.props.accessible; } @@ -125,22 +136,50 @@ export function isAccessibilityElement(element: ReactTestInstance | null): boole export function getRole(element: ReactTestInstance): Role | AccessibilityRole { const explicitRole = element.props.role ?? element.props.accessibilityRole; if (explicitRole) { - return explicitRole; + return normalizeRole(explicitRole); } if (isHostText(element)) { return 'text'; } + // Note: host Image elements report "image" role in screen reader only on Android, but not on iOS. + // It's better to require explicit role for Image elements. + return 'none'; } +/** + * There are some duplications between (ARIA) `Role` and `AccessibilityRole` types. + * Resolve them by using ARIA `Role` type where possible. + * + * @param role Role to normalize + * @returns Normalized role + */ +export function normalizeRole(role: string): Role | AccessibilityRole { + if (role === 'image') { + return 'img'; + } + + return role as Role | AccessibilityRole; +} + export function computeAriaModal(element: ReactTestInstance): boolean | undefined { return element.props['aria-modal'] ?? element.props.accessibilityViewIsModal; } export function computeAriaLabel(element: ReactTestInstance): string | undefined { - return element.props['aria-label'] ?? element.props.accessibilityLabel; + const explicitLabel = element.props['aria-label'] ?? element.props.accessibilityLabel; + if (explicitLabel) { + return explicitLabel; + } + + //https://github.com/facebook/react-native/blob/8dabed60f456e76a9e53273b601446f34de41fb5/packages/react-native/Libraries/Image/Image.ios.js#L173 + if (isHostImage(element) && element.props.alt) { + return element.props.alt; + } + + return undefined; } export function computeAriaLabelledBy(element: ReactTestInstance): string | undefined { @@ -154,12 +193,17 @@ export function computeAriaBusy({ props }: ReactTestInstance): boolean { // See: https://github.com/callstack/react-native-testing-library/wiki/Accessibility:-State#checked-state export function computeAriaChecked(element: ReactTestInstance): AccessibilityState['checked'] { + const { props } = element; + + if (isHostSwitch(element)) { + return props.value; + } + const role = getRole(element); - if (role !== 'checkbox' && role !== 'radio') { + if (!rolesSupportingCheckedState[role]) { return undefined; } - const props = element.props; return props['aria-checked'] ?? props.accessibilityState?.checked; } @@ -217,3 +261,11 @@ export function computeAccessibleName(element: ReactTestInstance): string | unde return getTextContent(element); } + +type RoleSupportMap = Partial>; + +export const rolesSupportingCheckedState: RoleSupportMap = { + checkbox: true, + radio: true, + switch: true, +}; diff --git a/src/helpers/format-default.ts b/src/helpers/format-default.ts index 22314075..7ee42b5b 100644 --- a/src/helpers/format-default.ts +++ b/src/helpers/format-default.ts @@ -9,6 +9,7 @@ const propsToDisplay = [ 'accessibilityLabelledBy', 'accessibilityRole', 'accessibilityViewIsModal', + 'alt', 'aria-busy', 'aria-checked', 'aria-disabled', diff --git a/src/helpers/host-component-names.tsx b/src/helpers/host-component-names.tsx index cd4b5223..b450c930 100644 --- a/src/helpers/host-component-names.tsx +++ b/src/helpers/host-component-names.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import { ReactTestInstance } from 'react-test-renderer'; -import { Modal, ScrollView, Switch, Text, TextInput, View } from 'react-native'; +import { Image, Modal, ScrollView, Switch, Text, TextInput, View } from 'react-native'; import { configureInternal, getConfig, HostComponentNames } from '../config'; import { renderWithAct } from '../render-act'; import { HostTestInstance } from './component-tree'; @@ -34,6 +34,7 @@ function detectHostComponentNames(): HostComponentNames { Hello + @@ -43,6 +44,7 @@ function detectHostComponentNames(): HostComponentNames { return { text: getByTestId(renderer.root, 'text').type as string, textInput: getByTestId(renderer.root, 'textInput').type as string, + image: getByTestId(renderer.root, 'image').type as string, switch: getByTestId(renderer.root, 'switch').type as string, scrollView: getByTestId(renderer.root, 'scrollView').type as string, modal: getByTestId(renderer.root, 'modal').type as string, @@ -85,6 +87,14 @@ export function isHostTextInput(element?: ReactTestInstance | null): element is return element?.type === getHostComponentNames().textInput; } +/** + * Checks if the given element is a host Image element. + * @param element The element to check. + */ +export function isHostImage(element?: ReactTestInstance | null): element is HostTestInstance { + return element?.type === getHostComponentNames().image; +} + /** * Checks if the given element is a host Switch element. * @param element The element to check. diff --git a/src/helpers/matchers/match-label-text.ts b/src/helpers/matchers/match-label-text.ts index 2aa5b9e5..1da29d86 100644 --- a/src/helpers/matchers/match-label-text.ts +++ b/src/helpers/matchers/match-label-text.ts @@ -18,10 +18,10 @@ export function matchLabelText( function matchAccessibilityLabel( element: ReactTestInstance, - extpectedLabel: TextMatch, + expectedLabel: TextMatch, options: TextMatchOptions, ) { - return matches(extpectedLabel, computeAriaLabel(element), options.normalizer, options.exact); + return matches(expectedLabel, computeAriaLabel(element), options.normalizer, options.exact); } function matchAccessibilityLabelledBy( diff --git a/src/helpers/text-input.ts b/src/helpers/text-input.ts index eaa10f7b..bf76389f 100644 --- a/src/helpers/text-input.ts +++ b/src/helpers/text-input.ts @@ -17,7 +17,7 @@ export function getTextInputValue(element: ReactTestInstance) { return ( element.props.value ?? - nativeState?.elementValues.get(element) ?? + nativeState.valueForElement.get(element) ?? element.props.defaultValue ?? '' ); diff --git a/src/matchers/__tests__/to-be-checked.test.tsx b/src/matchers/__tests__/to-be-checked.test.tsx index 872a08ae..85dc39aa 100644 --- a/src/matchers/__tests__/to-be-checked.test.tsx +++ b/src/matchers/__tests__/to-be-checked.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { type AccessibilityRole, View } from 'react-native'; +import { type AccessibilityRole, Switch, View } from 'react-native'; import render from '../../render'; import { screen } from '../../screen'; import '../extend-expect'; @@ -30,7 +30,56 @@ function renderViewsWithRole(role: AccessibilityRole) { ); } -test('toBeCheck() with checkbox role', () => { +test('toBeCheck() with Switch', () => { + render( + <> + + + + , + ); + + const checked = screen.getByTestId('checked'); + const unchecked = screen.getByTestId('unchecked'); + const defaultView = screen.getByTestId('default'); + + expect(checked).toBeChecked(); + expect(unchecked).not.toBeChecked(); + expect(defaultView).not.toBeChecked(); + + expect(() => expect(checked).not.toBeChecked()).toThrowErrorMatchingInlineSnapshot(` + "expect(element).not.toBeChecked() + + Received element is checked: + " + `); + expect(() => expect(unchecked).toBeChecked()).toThrowErrorMatchingInlineSnapshot(` + "expect(element).toBeChecked() + + Received element is not checked: + " + `); + expect(() => expect(defaultView).toBeChecked()).toThrowErrorMatchingInlineSnapshot(` + "expect(element).toBeChecked() + + Received element is not checked: + " + `); +}); + +test('toBeCheck() with "checkbox" role', () => { renderViewsWithRole('checkbox'); const checked = screen.getByTestId('checkbox-checked'); @@ -100,7 +149,7 @@ test('toBeCheck() with checkbox role', () => { `); }); -test('toBeCheck() with radio role', () => { +test('toBeCheck() with "radio" role', () => { renderViewsWithRole('radio'); const checked = screen.getByTestId('radio-checked'); @@ -153,6 +202,59 @@ test('toBeCheck() with radio role', () => { `); }); +test('toBeCheck() with "switch" role', () => { + renderViewsWithRole('switch'); + + const checked = screen.getByTestId('switch-checked'); + const unchecked = screen.getByTestId('switch-unchecked'); + const defaultView = screen.getByTestId('switch-default'); + + expect(checked).toBeChecked(); + expect(unchecked).not.toBeChecked(); + expect(defaultView).not.toBeChecked(); + + expect(() => expect(checked).not.toBeChecked()).toThrowErrorMatchingInlineSnapshot(` + "expect(element).not.toBeChecked() + + Received element is checked: + " + `); + expect(() => expect(unchecked).toBeChecked()).toThrowErrorMatchingInlineSnapshot(` + "expect(element).toBeChecked() + + Received element is not checked: + " + `); + expect(() => expect(defaultView).toBeChecked()).toThrowErrorMatchingInlineSnapshot(` + "expect(element).toBeChecked() + + Received element is not checked: + " + `); +}); + test('throws error for invalid role', () => { renderViewsWithRole('adjustable'); @@ -160,10 +262,10 @@ test('throws error for invalid role', () => { const unchecked = screen.getByTestId('adjustable-unchecked'); expect(() => expect(checked).toBeChecked()).toThrowErrorMatchingInlineSnapshot( - `"toBeChecked() works only on accessibility elements with "checkbox" or "radio" role."`, + `"toBeChecked() works only on host "Switch" elements or accessibility elements with "checkbox", "radio" or "switch" role."`, ); expect(() => expect(unchecked).not.toBeChecked()).toThrowErrorMatchingInlineSnapshot( - `"toBeChecked() works only on accessibility elements with "checkbox" or "radio" role."`, + `"toBeChecked() works only on host "Switch" elements or accessibility elements with "checkbox", "radio" or "switch" role."`, ); }); @@ -172,6 +274,6 @@ test('throws error for non-accessibility element', () => { const view = screen.getByTestId('test'); expect(() => expect(view).toBeChecked()).toThrowErrorMatchingInlineSnapshot( - `"toBeChecked() works only on accessibility elements with "checkbox" or "radio" role."`, + `"toBeChecked() works only on host "Switch" elements or accessibility elements with "checkbox", "radio" or "switch" role."`, ); }); diff --git a/src/matchers/__tests__/to-have-accessible-name.test.tsx b/src/matchers/__tests__/to-have-accessible-name.test.tsx index f4ef9612..4bb8f92c 100644 --- a/src/matchers/__tests__/to-have-accessible-name.test.tsx +++ b/src/matchers/__tests__/to-have-accessible-name.test.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { View, Text, TextInput } from 'react-native'; +import { View, Text, TextInput, Image } from 'react-native'; import { render, screen } from '../..'; import '../extend-expect'; @@ -72,17 +72,26 @@ test('toHaveAccessibleName() handles view with "aria-labelledby" prop', () => { expect(element).not.toHaveAccessibleName('Other label'); }); -test('toHaveAccessibleName() handles view with implicit accessible name', () => { +test('toHaveAccessibleName() handles Text with text content', () => { render(Text); + const element = screen.getByTestId('view'); expect(element).toHaveAccessibleName('Text'); expect(element).not.toHaveAccessibleName('Other text'); }); +test('toHaveAccessibleName() handles Image with "alt" prop', () => { + render(Test image); + + const element = screen.getByTestId('image'); + expect(element).toHaveAccessibleName('Test image'); + expect(element).not.toHaveAccessibleName('Other text'); +}); + test('toHaveAccessibleName() supports calling without expected name', () => { render(); - const element = screen.getByTestId('view'); + const element = screen.getByTestId('view'); expect(element).toHaveAccessibleName(); expect(() => expect(element).not.toHaveAccessibleName()).toThrowErrorMatchingInlineSnapshot(` "expect(element).not.toHaveAccessibleName() diff --git a/src/matchers/to-be-checked.tsx b/src/matchers/to-be-checked.tsx index 57defac1..fb9be392 100644 --- a/src/matchers/to-be-checked.tsx +++ b/src/matchers/to-be-checked.tsx @@ -1,15 +1,21 @@ import type { ReactTestInstance } from 'react-test-renderer'; import { matcherHint } from 'jest-matcher-utils'; -import { computeAriaChecked, getRole, isAccessibilityElement } from '../helpers/accessibility'; +import { + computeAriaChecked, + getRole, + isAccessibilityElement, + rolesSupportingCheckedState, +} from '../helpers/accessibility'; import { ErrorWithStack } from '../helpers/errors'; +import { isHostSwitch } from '../helpers/host-component-names'; import { checkHostElement, formatElement } from './utils'; export function toBeChecked(this: jest.MatcherContext, element: ReactTestInstance) { checkHostElement(element, toBeChecked, this); - if (!hasValidAccessibilityRole(element)) { + if (!isHostSwitch(element) && !isSupportedAccessibilityElement(element)) { throw new ErrorWithStack( - `toBeChecked() works only on accessibility elements with "checkbox" or "radio" role.`, + `toBeChecked() works only on host "Switch" elements or accessibility elements with "checkbox", "radio" or "switch" role.`, toBeChecked, ); } @@ -28,11 +34,11 @@ export function toBeChecked(this: jest.MatcherContext, element: ReactTestInstanc }; } -function hasValidAccessibilityRole(element: ReactTestInstance) { +function isSupportedAccessibilityElement(element: ReactTestInstance) { if (!isAccessibilityElement(element)) { return false; } const role = getRole(element); - return role === 'checkbox' || role === 'radio'; + return rolesSupportingCheckedState[role]; } diff --git a/src/native-state.ts b/src/native-state.ts index 48793fd3..46f53141 100644 --- a/src/native-state.ts +++ b/src/native-state.ts @@ -1,4 +1,5 @@ import { ReactTestInstance } from 'react-test-renderer'; +import { Point } from './types'; /** * Simulated native state for unmanaged controls. @@ -6,17 +7,11 @@ import { ReactTestInstance } from 'react-test-renderer'; * Values from `value` props (managed controls) should take precedence over these values. */ export type NativeState = { - elementValues: WeakMap; + valueForElement: WeakMap; + contentOffsetForElement: WeakMap; }; -export let nativeState: NativeState | null = null; - -export function initNativeState(): void { - nativeState = { - elementValues: new WeakMap(), - }; -} - -export function clearNativeState(): void { - nativeState = null; -} +export let nativeState: NativeState = { + valueForElement: new WeakMap(), + contentOffsetForElement: new WeakMap(), +}; diff --git a/src/queries/__tests__/hint-text.test.tsx b/src/queries/__tests__/hint-text.test.tsx index 1292bb7b..2ff9f841 100644 --- a/src/queries/__tests__/hint-text.test.tsx +++ b/src/queries/__tests__/hint-text.test.tsx @@ -8,11 +8,11 @@ const TEXT_HINT = 'static text'; const NO_MATCHES_TEXT: any = 'not-existent-element'; const getMultipleInstancesFoundMessage = (value: string) => { - return `Found multiple elements with accessibilityHint: ${value}`; + return `Found multiple elements with accessibility hint: ${value}`; }; const getNoInstancesFoundMessage = (value: string) => { - return `Unable to find an element with accessibilityHint: ${value}`; + return `Unable to find an element with accessibility hint: ${value}`; }; const Typography = ({ children, ...rest }: any) => { @@ -114,7 +114,7 @@ test('byHintText queries support hidden option', () => { expect(screen.queryByHintText('hidden', { includeHiddenElements: false })).toBeFalsy(); expect(() => screen.getByHintText('hidden', { includeHiddenElements: false })) .toThrowErrorMatchingInlineSnapshot(` - "Unable to find an element with accessibilityHint: hidden + "Unable to find an element with accessibility hint: hidden ); expect(() => screen.getByHintText('FOO')).toThrowErrorMatchingInlineSnapshot(` - "Unable to find an element with accessibilityHint: FOO + "Unable to find an element with accessibility hint: FOO screen.getAllByHintText('FOO')).toThrowErrorMatchingInlineSnapshot(` - "Unable to find an element with accessibilityHint: FOO + "Unable to find an element with accessibility hint: FOO { expect(screen.getByLabelText(/nested text label/i)).toBe(screen.getByTestId('text-input')); }); +test('getByLabelText supports "Image"" with "alt" prop', () => { + render( + <> + Image Label + , + ); + + const expectedElement = screen.getByTestId('image'); + expect(screen.getByLabelText('Image Label')).toBe(expectedElement); + expect(screen.getByLabelText(/image label/i)).toBe(expectedElement); +}); + test('error message renders the element tree, preserving only helpful props', async () => { render(); diff --git a/src/queries/__tests__/role-value.test.tsx b/src/queries/__tests__/role-value.test.tsx index 56a6e38c..4b56a8de 100644 --- a/src/queries/__tests__/role-value.test.tsx +++ b/src/queries/__tests__/role-value.test.tsx @@ -77,7 +77,7 @@ describe('accessibility value', () => { expect(() => screen.getByRole('adjustable', { name: 'Hello', value: { min: 5 } })) .toThrowErrorMatchingInlineSnapshot(` - "Unable to find an element with role: "adjustable", name: "Hello", min value: 5 + "Unable to find an element with role: adjustable, name: Hello, min value: 5 { `); expect(() => screen.getByRole('adjustable', { name: 'World', value: { min: 10 } })) .toThrowErrorMatchingInlineSnapshot(` - "Unable to find an element with role: "adjustable", name: "World", min value: 10 + "Unable to find an element with role: adjustable, name: World, min value: 10 { `); expect(() => screen.getByRole('adjustable', { name: 'Hello', value: { min: 5 } })) .toThrowErrorMatchingInlineSnapshot(` - "Unable to find an element with role: "adjustable", name: "Hello", min value: 5 + "Unable to find an element with role: adjustable, name: Hello, min value: 5 { `); expect(() => screen.getByRole('adjustable', { selected: true, value: { min: 10 } })) .toThrowErrorMatchingInlineSnapshot(` - "Unable to find an element with role: "adjustable", selected state: true, min value: 10 + "Unable to find an element with role: adjustable, selected state: true, min value: 10 { - return `Found multiple elements with role: "${value}"`; + return `Found multiple elements with role: ${value}`; }; const getNoInstancesFoundMessage = (value: string) => { - return `Unable to find an element with role: "${value}"`; + return `Unable to find an element with role: ${value}`; }; const Typography = ({ children, ...rest }: any) => { @@ -223,6 +225,25 @@ describe('supports name option', () => { expect(screen.getByRole('header', { name: 'About' })).toBe(screen.getByTestId('target-header')); expect(screen.getByRole('header', { name: 'About' }).props.testID).toBe('target-header'); }); + + test('supports host Image element with "alt" prop', () => { + render( + <> + an elephant + a tiger + , + ); + + const expectedElement1 = screen.getByTestId('image1'); + expect(screen.getByRole('img', { name: 'an elephant' })).toBe(expectedElement1); + expect(screen.getByRole('image', { name: 'an elephant' })).toBe(expectedElement1); + expect(screen.getByRole(/img/, { name: /elephant/ })).toBe(expectedElement1); + + const expectedElement2 = screen.getByTestId('image2'); + expect(screen.getByRole('img', { name: 'a tiger' })).toBe(expectedElement2); + expect(screen.getByRole('image', { name: 'a tiger' })).toBe(expectedElement2); + expect(screen.getByRole(/img/, { name: /tiger/ })).toBe(expectedElement2); + }); }); describe('supports accessibility states', () => { @@ -426,7 +447,7 @@ describe('supports accessibility states', () => { expect(screen.queryByRole('checkbox', { checked: 'mixed' })).toBe(null); }); - it('returns `mixed` checkboxes', () => { + test('returns `mixed` checkboxes', () => { render( , ); @@ -508,6 +529,14 @@ describe('supports accessibility states', () => { expect(screen.queryByRole('checkbox', { checked: false })).toBe(null); }); + test('supports "Switch" component', () => { + render(); + + expect(screen.getByRole('switch', { checked: true })).toBeTruthy(); + expect(screen.queryByRole('switch', { checked: false })).toBe(null); + expect(screen.queryByRole('switch', { checked: 'mixed' })).toBe(null); + }); + test('supports aria-checked={true} prop', () => { render(); expect(screen.getByRole('checkbox', { checked: true })).toBeTruthy(); @@ -759,7 +788,7 @@ describe('error messages', () => { render(); expect(() => screen.getByRole('button')).toThrowErrorMatchingInlineSnapshot(` - "Unable to find an element with role: "button" + "Unable to find an element with role: button " `); @@ -769,7 +798,7 @@ describe('error messages', () => { render(); expect(() => screen.getByRole('button', { name: 'Save' })).toThrowErrorMatchingInlineSnapshot(` - "Unable to find an element with role: "button", name: "Save" + "Unable to find an element with role: button, name: Save " `); @@ -780,7 +809,7 @@ describe('error messages', () => { expect(() => screen.getByRole('button', { name: 'Save', disabled: true })) .toThrowErrorMatchingInlineSnapshot(` - "Unable to find an element with role: "button", name: "Save", disabled state: true + "Unable to find an element with role: button, name: Save, disabled state: true " `); @@ -791,7 +820,7 @@ describe('error messages', () => { expect(() => screen.getByRole('button', { name: 'Save', disabled: true, selected: true })) .toThrowErrorMatchingInlineSnapshot(` - "Unable to find an element with role: "button", name: "Save", disabled state: true, selected state: true + "Unable to find an element with role: button, name: Save, disabled state: true, selected state: true " `); @@ -802,7 +831,7 @@ describe('error messages', () => { expect(() => screen.getByRole('button', { disabled: true })) .toThrowErrorMatchingInlineSnapshot(` - "Unable to find an element with role: "button", disabled state: true + "Unable to find an element with role: button, disabled state: true " `); @@ -813,7 +842,7 @@ describe('error messages', () => { expect(() => screen.getByRole('adjustable', { value: { min: 1 } })) .toThrowErrorMatchingInlineSnapshot(` - "Unable to find an element with role: "adjustable", min value: 1 + "Unable to find an element with role: adjustable, min value: 1 " `); @@ -823,7 +852,7 @@ describe('error messages', () => { value: { min: 1, max: 2, now: 1, text: /hello/ }, }), ).toThrowErrorMatchingInlineSnapshot(` - "Unable to find an element with role: "adjustable", min value: 1, max value: 2, now value: 1, text value: /hello/ + "Unable to find an element with role: adjustable, min value: 1, max value: 2, now value: 1, text value: /hello/ " `); @@ -843,7 +872,7 @@ test('byRole queries support hidden option', () => { expect(screen.queryByRole('button', { includeHiddenElements: false })).toBeFalsy(); expect(() => screen.getByRole('button', { includeHiddenElements: false })) .toThrowErrorMatchingInlineSnapshot(` - "Unable to find an element with role: "button" + "Unable to find an element with role: button { expect(screen.queryByRole('button', { name: 'Action' })).toBeFalsy(); }); - test('ignores elements with accessible={undefined} and that are implicitely not accessible', () => { + test('ignores elements with accessible={undefined} and that are implicitly not accessible', () => { render( Action @@ -894,7 +923,7 @@ test('error message renders the element tree, preserving only helpful props', as render(); expect(() => screen.getByRole('link')).toThrowErrorMatchingInlineSnapshot(` - "Unable to find an element with role: "link" + "Unable to find an element with role: link screen.getAllByRole('link')).toThrowErrorMatchingInlineSnapshot(` - "Unable to find an element with role: "link" + "Unable to find an element with role: link - `Found multiple elements with accessibilityHint: ${String(hint)} `; + `Found multiple elements with accessibility hint: ${String(hint)} `; const getMissingError = (hint: TextMatch) => - `Unable to find an element with accessibilityHint: ${String(hint)}`; + `Unable to find an element with accessibility hint: ${String(hint)}`; const { getBy, getAllBy, queryBy, queryAllBy, findBy, findAllBy } = makeQueries( queryAllByHintText, diff --git a/src/queries/role.ts b/src/queries/role.ts index a806bf05..9b30a4af 100644 --- a/src/queries/role.ts +++ b/src/queries/role.ts @@ -5,6 +5,7 @@ import { accessibilityValueKeys, getRole, isAccessibilityElement, + normalizeRole, } from '../helpers/accessibility'; import { findAll } from '../helpers/find-all'; import { @@ -60,12 +61,13 @@ const queryAllByRole = ( instance: ReactTestInstance, ): QueryAllByQuery => function queryAllByRoleFn(role, options) { + const normalizedRole = typeof role === 'string' ? normalizeRole(role) : role; return findAll( instance, (node) => // run the cheapest checks first, and early exit to avoid unneeded computations isAccessibilityElement(node) && - matchStringProp(getRole(node), role) && + matchStringProp(getRole(node), normalizedRole) && matchAccessibleStateIfNeeded(node, options) && matchAccessibilityValueIfNeeded(node, options?.value) && matchAccessibleNameIfNeeded(node, options?.name), @@ -74,10 +76,10 @@ const queryAllByRole = ( }; const formatQueryParams = (role: TextMatch, options: ByRoleOptions = {}) => { - const params = [`role: "${String(role)}"`]; + const params = [`role: ${String(role)}`]; if (options.name) { - params.push(`name: "${String(options.name)}"`); + params.push(`name: ${String(options.name)}`); } accessibilityStateKeys.forEach((stateKey) => { diff --git a/src/render.tsx b/src/render.tsx index 1e354038..208423c6 100644 --- a/src/render.tsx +++ b/src/render.tsx @@ -11,7 +11,6 @@ import { validateStringsRenderedWithinText } from './helpers/string-validation'; import { renderWithAct } from './render-act'; import { setRenderResult } from './screen'; import { getQueriesForElement } from './within'; -import { initNativeState } from './native-state'; export interface RenderOptions { wrapper?: React.ComponentType; @@ -127,7 +126,6 @@ function buildRenderResult( }); setRenderResult(result); - initNativeState(); return result; } diff --git a/src/types.ts b/src/types.ts index 77df0fb3..293e7e81 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,19 @@ +/** + * Location of an element. + */ +export interface Point { + y: number; + x: number; +} + +/** + * Size of an element. + */ +export interface Size { + height: number; + width: number; +} + // TS autocomplete trick // Ref: https://github.com/microsoft/TypeScript/issues/29729#issuecomment-567871939 export type StringWithAutocomplete = T | (string & {}); diff --git a/src/user-event/event-builder/scroll-view.ts b/src/user-event/event-builder/scroll-view.ts index c477b6c2..bf5e3dec 100644 --- a/src/user-event/event-builder/scroll-view.ts +++ b/src/user-event/event-builder/scroll-view.ts @@ -1,25 +1,12 @@ +import { Point, Size } from '../../types'; import { baseSyntheticEvent } from './base'; -/** - * Scroll position of a scrollable element. - */ -export interface ContentOffset { - y: number; - x: number; -} - /** * Other options for constructing a scroll event. */ export type ScrollEventOptions = { - contentSize?: { - height: number; - width: number; - }; - layoutMeasurement?: { - height: number; - width: number; - }; + contentSize?: Size; + layoutMeasurement?: Size; }; /** @@ -28,7 +15,7 @@ export type ScrollEventOptions = { * - Android: `{"contentInset": {"bottom": 0, "left": 0, "right": 0, "top": 0}, "contentOffset": {"x": 0, "y": 31.619047164916992}, "contentSize": {"height": 1624.761962890625, "width": 411.4285583496094}, "layoutMeasurement": {"height": 785.5238037109375, "width": 411.4285583496094}, "responderIgnoreScroll": true, "target": 139, "velocity": {"x": -1.3633992671966553, "y": -1.3633992671966553}}` */ export const ScrollViewEventBuilder = { - scroll: (offset: ContentOffset = { y: 0, x: 0 }, options?: ScrollEventOptions) => { + scroll: (offset: Point = { y: 0, x: 0 }, options?: ScrollEventOptions) => { return { ...baseSyntheticEvent(), nativeEvent: { diff --git a/src/user-event/event-builder/text-input.ts b/src/user-event/event-builder/text-input.ts index 61ea3c06..2ec4af97 100644 --- a/src/user-event/event-builder/text-input.ts +++ b/src/user-event/event-builder/text-input.ts @@ -1,4 +1,4 @@ -import { ContentSize } from '../utils/content-size'; +import { Size } from '../../types'; import { TextRange } from '../utils/text-range'; import { baseSyntheticEvent } from './base'; @@ -68,7 +68,7 @@ export const TextInputEventBuilder = { * - iOS: `{"contentSize": {"height": 21.666666666666668, "width": 11.666666666666666}, "target": 75}` * - Android: `{"contentSize": {"height": 61.45454406738281, "width": 352.7272644042969}, "target": 53}` */ - contentSizeChange: ({ width, height }: ContentSize) => { + contentSizeChange: ({ width, height }: Size) => { return { ...baseSyntheticEvent(), nativeEvent: { contentSize: { width, height }, target: 0 }, diff --git a/src/user-event/paste.ts b/src/user-event/paste.ts index ccdaff71..856501bf 100644 --- a/src/user-event/paste.ts +++ b/src/user-event/paste.ts @@ -33,7 +33,7 @@ export async function paste( dispatchEvent(element, 'selectionChange', EventBuilder.TextInput.selectionChange(rangeToClear)); // 3. Paste the text - nativeState?.elementValues.set(element, text); + nativeState.valueForElement.set(element, text); dispatchEvent(element, 'change', EventBuilder.TextInput.change(text)); dispatchEvent(element, 'changeText', text); diff --git a/src/user-event/scroll/__tests__/scroll-to.test.tsx b/src/user-event/scroll/__tests__/scroll-to.test.tsx index e3288c22..684deff9 100644 --- a/src/user-event/scroll/__tests__/scroll-to.test.tsx +++ b/src/user-event/scroll/__tests__/scroll-to.test.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { ScrollView, ScrollViewProps, View } from 'react-native'; import { EventEntry, createEventLogger } from '../../../test-utils'; -import { render, screen } from '../../..'; +import { fireEvent, render, screen } from '../../..'; import { userEvent } from '../..'; function mapEventsToShortForm(events: EventEntry[]) { @@ -103,7 +103,7 @@ describe('scrollTo()', () => { ]); }); - test('remembers previous scroll position', async () => { + test('remembers previous scroll offset', async () => { const { events } = renderScrollViewWithToolkit(); const user = userEvent.setup(); @@ -123,6 +123,24 @@ describe('scrollTo()', () => { ]); }); + test('remembers previous scroll offset from "fireEvent.scroll"', async () => { + const { events } = renderScrollViewWithToolkit(); + const user = userEvent.setup(); + + fireEvent.scroll(screen.getByTestId('scrollView'), { + nativeEvent: { contentOffset: { y: 100 } }, + }); + await user.scrollTo(screen.getByTestId('scrollView'), { y: 200 }); + expect(mapEventsToShortForm(events)).toEqual([ + ['scroll', 100, undefined], + ['scrollBeginDrag', 100, 0], + ['scroll', 125, 0], + ['scroll', 150, 0], + ['scroll', 175, 0], + ['scrollEndDrag', 200, 0], + ]); + }); + it('validates vertical scroll direction', async () => { renderScrollViewWithToolkit(); const user = userEvent.setup(); diff --git a/src/user-event/scroll/scroll-to.ts b/src/user-event/scroll/scroll-to.ts index c280e628..b90cc219 100644 --- a/src/user-event/scroll/scroll-to.ts +++ b/src/user-event/scroll/scroll-to.ts @@ -5,20 +5,14 @@ import { EventBuilder } from '../event-builder'; import { ErrorWithStack } from '../../helpers/errors'; import { isHostScrollView } from '../../helpers/host-component-names'; import { pick } from '../../helpers/object'; -import { ContentOffset } from '../event-builder/scroll-view'; +import { nativeState } from '../../native-state'; +import { Point, Size } from '../../types'; import { dispatchEvent, wait } from '../utils'; import { createScrollSteps, inertialInterpolator, linearInterpolator } from './utils'; -import { getElementScrollOffset, setElementScrollOffset } from './state'; interface CommonScrollToOptions { - contentSize?: { - height: number; - width: number; - }; - layoutMeasurement?: { - height: number; - width: number; - }; + contentSize?: Size; + layoutMeasurement?: Size; } export interface VerticalScrollToOptions extends CommonScrollToOptions { @@ -62,15 +56,15 @@ export async function scrollTo( options.contentSize?.height ?? 0, ); - const initialPosition = getElementScrollOffset(element); + const initialOffset = nativeState.contentOffsetForElement.get(element) ?? { x: 0, y: 0 }; const dragSteps = createScrollSteps( { y: options.y, x: options.x }, - initialPosition, + initialOffset, linearInterpolator, ); await emitDragScrollEvents(this.config, element, dragSteps, options); - const momentumStart = dragSteps.at(-1) ?? initialPosition; + const momentumStart = dragSteps.at(-1) ?? initialOffset; const momentumSteps = createScrollSteps( { y: options.momentumY, x: options.momentumX }, momentumStart, @@ -78,14 +72,14 @@ export async function scrollTo( ); await emitMomentumScrollEvents(this.config, element, momentumSteps, options); - const finalPosition = momentumSteps.at(-1) ?? dragSteps.at(-1) ?? initialPosition; - setElementScrollOffset(element, finalPosition); + const finalOffset = momentumSteps.at(-1) ?? dragSteps.at(-1) ?? initialOffset; + nativeState.contentOffsetForElement.set(element, finalOffset); } async function emitDragScrollEvents( config: UserEventConfig, element: ReactTestInstance, - scrollSteps: ContentOffset[], + scrollSteps: Point[], scrollOptions: ScrollToOptions, ) { if (scrollSteps.length === 0) { @@ -115,7 +109,7 @@ async function emitDragScrollEvents( async function emitMomentumScrollEvents( config: UserEventConfig, element: ReactTestInstance, - scrollSteps: ContentOffset[], + scrollSteps: Point[], scrollOptions: ScrollToOptions, ) { if (scrollSteps.length === 0) { diff --git a/src/user-event/scroll/state.ts b/src/user-event/scroll/state.ts deleted file mode 100644 index 49fa109d..00000000 --- a/src/user-event/scroll/state.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { ReactTestInstance } from 'react-test-renderer'; -import { ContentOffset } from '../event-builder/scroll-view'; - -const scrollOffsetForElement = new WeakMap(); - -export function getElementScrollOffset(element: ReactTestInstance): ContentOffset { - return scrollOffsetForElement.get(element) ?? { x: 0, y: 0 }; -} - -export function setElementScrollOffset(element: ReactTestInstance, scrollState: ContentOffset) { - scrollOffsetForElement.set(element, scrollState); -} diff --git a/src/user-event/scroll/utils.ts b/src/user-event/scroll/utils.ts index a683ad9d..7fe46590 100644 --- a/src/user-event/scroll/utils.ts +++ b/src/user-event/scroll/utils.ts @@ -1,14 +1,14 @@ -import { ContentOffset } from '../event-builder/scroll-view'; +import { Point } from '../../types'; const DEFAULT_STEPS_COUNT = 5; type InterpolatorFn = (end: number, start: number, steps: number) => number[]; export function createScrollSteps( - target: Partial, - initialOffset: ContentOffset, + target: Partial, + initialOffset: Point, interpolator: InterpolatorFn, -): ContentOffset[] { +): Point[] { if (target.y != null) { return interpolator(target.y, initialOffset.y, DEFAULT_STEPS_COUNT).map((y) => ({ y, diff --git a/src/user-event/type/type.ts b/src/user-event/type/type.ts index 7d3a8e6d..540a2879 100644 --- a/src/user-event/type/type.ts +++ b/src/user-event/type/type.ts @@ -95,7 +95,7 @@ export async function emitTypingEvents( return; } - nativeState?.elementValues.set(element, text); + nativeState.valueForElement.set(element, text); dispatchEvent(element, 'change', EventBuilder.TextInput.change(text)); dispatchEvent(element, 'changeText', text); diff --git a/src/user-event/utils/content-size.ts b/src/user-event/utils/content-size.ts index 51f10008..bc329159 100644 --- a/src/user-event/utils/content-size.ts +++ b/src/user-event/utils/content-size.ts @@ -1,7 +1,4 @@ -export interface ContentSize { - width: number; - height: number; -} +import { Size } from '../../types'; /** * Simple function for getting mock the size of given text. @@ -13,8 +10,7 @@ export interface ContentSize { * @param text text to be measure * @returns width and height of the text */ - -export function getTextContentSize(text: string): ContentSize { +export function getTextContentSize(text: string): Size { const lines = text.split('\n'); const maxLineLength = Math.max(...lines.map((line) => line.length)); diff --git a/website/docs/12.x/docs/api/events/user-event.mdx b/website/docs/12.x/docs/api/events/user-event.mdx index 68baceb6..5a8379fe 100644 --- a/website/docs/12.x/docs/api/events/user-event.mdx +++ b/website/docs/12.x/docs/api/events/user-event.mdx @@ -269,10 +269,10 @@ Each scroll interaction consists of a mandatory drag scroll part, which simulate ### Options {#scroll-to-options} -- `y` - target vertical drag scroll position -- `x` - target horizontal drag scroll position -- `momentumY` - target vertical momentum scroll position -- `momentumX` - target horizontal momentum scroll position +- `y` - target vertical drag scroll offset +- `x` - target horizontal drag scroll offset +- `momentumY` - target vertical momentum scroll offset +- `momentumX` - target horizontal momentum scroll offset - `contentSize` - passed to `ScrollView` events and enabling `FlatList` updates - `layoutMeasurement` - passed to `ScrollView` events and enabling `FlatList` updates diff --git a/website/docs/12.x/docs/api/jest-matchers.mdx b/website/docs/12.x/docs/api/jest-matchers.mdx index d013c6b9..26255ada 100644 --- a/website/docs/12.x/docs/api/jest-matchers.mdx +++ b/website/docs/12.x/docs/api/jest-matchers.mdx @@ -140,8 +140,8 @@ These allow you to assert whether the given element is checked or partially chec :::note -- `toBeChecked()` matcher works only on elements with the `checkbox` or `radio` role. -- `toBePartiallyChecked()` matcher works only on elements with the `checkbox` role. +- `toBeChecked()` matcher works only on `Switch` host elements and accessibility elements with `checkbox`, `radio` or `switch` role. +- `toBePartiallyChecked()` matcher works only on elements with `checkbox` role. ::: diff --git a/website/docs/12.x/docs/guides/troubleshooting.mdx b/website/docs/12.x/docs/guides/troubleshooting.mdx index cacbe58f..fafab722 100644 --- a/website/docs/12.x/docs/guides/troubleshooting.mdx +++ b/website/docs/12.x/docs/guides/troubleshooting.mdx @@ -10,7 +10,7 @@ Check that you have matching versions of core dependencies: - React - React Test Renderer -React Native uses different versioning scheme from React, you can use [React Native Upgrade Helper](https://react-native-community.github.io/upgrade-helper/) to find the correct matching between React Native & React versions. In case you use Expo, you should use dependency versions recommended by them and set by `expo upgrade` command. +React Native uses different versioning scheme from React, you can use [React Native Upgrade Helper](https://react-native-community.github.io/upgrade-helper/) to find the correct matching between React Native & React versions. In case you use Expo, run `npx expo install --fix` in your project to validate and install compatible versions of these dependencies. React Test Renderer usually has same major & minor version as React, as they are closely related and React Test Renderer is part of [React monorepo](https://github.com/facebook/react). diff --git a/website/docs/12.x/docs/migration/jest-matchers.mdx b/website/docs/12.x/docs/migration/jest-matchers.mdx index d81367e4..1ab9e05a 100644 --- a/website/docs/12.x/docs/migration/jest-matchers.mdx +++ b/website/docs/12.x/docs/migration/jest-matchers.mdx @@ -71,5 +71,5 @@ New [`toHaveAccessibleName()`](docs/api/jest-matchers#tohaveaccessiblename) has You should be aware of the following details: - [`toBeEnabled()` / `toBeDisabled()`](docs/api/jest-matchers#tobeenabled) matchers also check the disabled state for the element's ancestors and not only the element itself. This is the same as in legacy Jest Native matchers of the same name but differs from the removed `toHaveAccessibilityState()` matcher. -- [`toBeChecked()`](docs/api/jest-matchers#tobechecked) matcher supports only elements with a `checkbox` or `radio` role +- [`toBeChecked()`](docs/api/jest-matchers#tobechecked) matcher supports only elements with a `checkbox`, `radio` and 'switch' role - [`toBePartiallyChecked()`](docs/api/jest-matchers#tobechecked) matcher supports only elements with `checkbox` role