Merge branch 'main' into v13

# Conflicts:
#	src/queries/__tests__/accessibility-state.test.tsx
This commit is contained in:
Maciej Jastrzebski
2024-09-12 09:30:11 +02:00
41 changed files with 597 additions and 306 deletions
+1 -1
View File
@@ -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",
+5 -5
View File
@@ -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"
+1 -1
View File
@@ -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",
+5 -5
View File
@@ -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"
+2 -3
View File
@@ -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/",
+2
View File
@@ -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',
+7 -1
View File
@@ -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(<View />);
+181 -145
View File
@@ -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: <View> renders single host element', () => {
test('React Native API assumption: <View> renders a single host element', () => {
render(<View testID="test" />);
expect(screen.toJSON()).toMatchInlineSnapshot(`
@@ -17,7 +17,7 @@ test('React Native API assumption: <View> renders single host element', () => {
`);
});
test('React Native API assumption: <Text> renders single host element', () => {
test('React Native API assumption: <Text> renders a single host element', () => {
render(<Text testID="test">Hello</Text>);
expect(screen.toJSON()).toMatchInlineSnapshot(`
@@ -29,7 +29,7 @@ test('React Native API assumption: <Text> renders single host element', () => {
`);
});
test('React Native API assumption: nested <Text> renders single host element', () => {
test('React Native API assumption: nested <Text> renders a single host element', () => {
render(
<Text testID="test">
<Text testID="before">Before</Text>
@@ -63,7 +63,7 @@ test('React Native API assumption: nested <Text> renders single host element', (
`);
});
test('React Native API assumption: <TextInput> renders single host element', () => {
test('React Native API assumption: <TextInput> renders a single host element', () => {
render(
<TextInput
testID="test"
@@ -102,7 +102,7 @@ test('React Native API assumption: <TextInput> with nested Text renders single h
`);
});
test('React Native API assumption: <Switch> renders single host element', () => {
test('React Native API assumption: <Switch> renders a single host element', () => {
render(<Switch testID="test" value={true} onChange={jest.fn()} />);
expect(screen.toJSON()).toMatchInlineSnapshot(`
@@ -123,151 +123,23 @@ test('React Native API assumption: <Switch> renders single host element', () =>
`);
});
test('React Native API assumption: aria-* props render on host View', () => {
render(
<View
testID="test"
aria-busy
aria-checked
aria-disabled
aria-expanded
aria-hidden
aria-label="Label"
aria-labelledby="LabelledBy"
aria-live="polite"
aria-modal
aria-pressed
aria-readonly
aria-required
aria-selected
aria-valuemax={10}
aria-valuemin={0}
aria-valuenow={5}
aria-valuetext="ValueText"
/>,
);
test('React Native API assumption: <Image> renders a single host element', () => {
render(<Image testID="test" source={{ uri: 'https://fake.url/image.jpg' }} alt="Alt text" />);
expect(screen.toJSON()).toMatchInlineSnapshot(`
<View
aria-busy={true}
aria-checked={true}
aria-disabled={true}
aria-expanded={true}
aria-hidden={true}
aria-label="Label"
aria-labelledby="LabelledBy"
aria-live="polite"
aria-modal={true}
aria-pressed={true}
aria-readonly={true}
aria-required={true}
aria-selected={true}
aria-valuemax={10}
aria-valuemin={0}
aria-valuenow={5}
aria-valuetext="ValueText"
<Image
alt="Alt text"
source={
{
"uri": "https://fake.url/image.jpg",
}
}
testID="test"
/>
`);
});
test('React Native API assumption: aria-* props render on host Text', () => {
render(
<Text
testID="test"
aria-busy
aria-checked
aria-disabled
aria-expanded
aria-hidden
aria-label="Label"
aria-labelledby="LabelledBy"
aria-live="polite"
aria-modal
aria-pressed
aria-readonly
aria-required
aria-selected
aria-valuemax={10}
aria-valuemin={0}
aria-valuenow={5}
aria-valuetext="ValueText"
/>,
);
expect(screen.toJSON()).toMatchInlineSnapshot(`
<Text
aria-busy={true}
aria-checked={true}
aria-disabled={true}
aria-expanded={true}
aria-hidden={true}
aria-label="Label"
aria-labelledby="LabelledBy"
aria-live="polite"
aria-modal={true}
aria-pressed={true}
aria-readonly={true}
aria-required={true}
aria-selected={true}
aria-valuemax={10}
aria-valuemin={0}
aria-valuenow={5}
aria-valuetext="ValueText"
testID="test"
/>
`);
});
test('React Native API assumption: aria-* props render on host TextInput', () => {
render(
<TextInput
testID="test"
aria-busy
aria-checked
aria-disabled
aria-expanded
aria-hidden
aria-label="Label"
aria-labelledby="LabelledBy"
aria-live="polite"
aria-modal
aria-pressed
aria-readonly
aria-required
aria-selected
aria-valuemax={10}
aria-valuemin={0}
aria-valuenow={5}
aria-valuetext="ValueText"
/>,
);
expect(screen.toJSON()).toMatchInlineSnapshot(`
<TextInput
aria-busy={true}
aria-checked={true}
aria-disabled={true}
aria-expanded={true}
aria-hidden={true}
aria-label="Label"
aria-labelledby="LabelledBy"
aria-live="polite"
aria-modal={true}
aria-pressed={true}
aria-readonly={true}
aria-required={true}
aria-selected={true}
aria-valuemax={10}
aria-valuemin={0}
aria-valuenow={5}
aria-valuetext="ValueText"
testID="test"
/>
`);
});
test('ScrollView renders correctly', () => {
test('React Native API assumption: <ScrollView> renders a single host element', () => {
render(
<ScrollView testID="scrollView">
<View testID="view" />
@@ -287,7 +159,7 @@ test('ScrollView renders correctly', () => {
`);
});
test('FlatList renders correctly', () => {
test('React Native API assumption: <FlatList> renders a single host <ScrollView> element', () => {
render(
<FlatList testID="flatList" data={[1, 2]} renderItem={({ item }) => <Text>{item}</Text>} />,
);
@@ -340,3 +212,167 @@ test('FlatList renders correctly', () => {
</RCTScrollView>
`);
});
test('React Native API assumption: <Modal> renders a single host element', () => {
render(
<Modal testID="test">
<Text>Modal Content</Text>
</Modal>,
);
expect(screen.toJSON()).toMatchInlineSnapshot(`
<Modal
hardwareAccelerated={false}
testID="test"
visible={true}
>
<Text>
Modal Content
</Text>
</Modal>
`);
});
test('React Native API assumption: aria-* props render directly on host View', () => {
render(
<View
testID="test"
aria-busy
aria-checked
aria-disabled
aria-expanded
aria-hidden
aria-label="Label"
aria-labelledby="LabelledBy"
aria-live="polite"
aria-modal
aria-pressed
aria-readonly
aria-required
aria-selected
aria-valuemax={10}
aria-valuemin={0}
aria-valuenow={5}
aria-valuetext="ValueText"
/>,
);
expect(screen.toJSON()).toMatchInlineSnapshot(`
<View
aria-busy={true}
aria-checked={true}
aria-disabled={true}
aria-expanded={true}
aria-hidden={true}
aria-label="Label"
aria-labelledby="LabelledBy"
aria-live="polite"
aria-modal={true}
aria-pressed={true}
aria-readonly={true}
aria-required={true}
aria-selected={true}
aria-valuemax={10}
aria-valuemin={0}
aria-valuenow={5}
aria-valuetext="ValueText"
testID="test"
/>
`);
});
test('React Native API assumption: aria-* props render directly on host Text', () => {
render(
<Text
testID="test"
aria-busy
aria-checked
aria-disabled
aria-expanded
aria-hidden
aria-label="Label"
aria-labelledby="LabelledBy"
aria-live="polite"
aria-modal
aria-pressed
aria-readonly
aria-required
aria-selected
aria-valuemax={10}
aria-valuemin={0}
aria-valuenow={5}
aria-valuetext="ValueText"
/>,
);
expect(screen.toJSON()).toMatchInlineSnapshot(`
<Text
aria-busy={true}
aria-checked={true}
aria-disabled={true}
aria-expanded={true}
aria-hidden={true}
aria-label="Label"
aria-labelledby="LabelledBy"
aria-live="polite"
aria-modal={true}
aria-pressed={true}
aria-readonly={true}
aria-required={true}
aria-selected={true}
aria-valuemax={10}
aria-valuemin={0}
aria-valuenow={5}
aria-valuetext="ValueText"
testID="test"
/>
`);
});
test('React Native API assumption: aria-* props render directly on host TextInput', () => {
render(
<TextInput
testID="test"
aria-busy
aria-checked
aria-disabled
aria-expanded
aria-hidden
aria-label="Label"
aria-labelledby="LabelledBy"
aria-live="polite"
aria-modal
aria-pressed
aria-readonly
aria-required
aria-selected
aria-valuemax={10}
aria-valuemin={0}
aria-valuenow={5}
aria-valuetext="ValueText"
/>,
);
expect(screen.toJSON()).toMatchInlineSnapshot(`
<TextInput
aria-busy={true}
aria-checked={true}
aria-disabled={true}
aria-expanded={true}
aria-hidden={true}
aria-label="Label"
aria-labelledby="LabelledBy"
aria-live="polite"
aria-modal={true}
aria-pressed={true}
aria-readonly={true}
aria-required={true}
aria-selected={true}
aria-valuemax={10}
aria-valuemin={0}
aria-valuenow={5}
aria-valuetext="ValueText"
testID="test"
/>
`);
});
-2
View File
@@ -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<CleanUpFunction>();
export default function cleanup() {
clearNativeState();
clearRenderResult();
cleanupQueue.forEach((fn) => fn());
+1
View File
@@ -23,6 +23,7 @@ export type ConfigAliasOptions = {
export type HostComponentNames = {
text: string;
textInput: string;
image: string;
switch: string;
scrollView: string;
modal: string;
+37 -3
View File
@@ -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;
}
+57 -5
View File
@@ -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<Record<Role | AccessibilityRole, true>>;
export const rolesSupportingCheckedState: RoleSupportMap = {
checkbox: true,
radio: true,
switch: true,
};
+1
View File
@@ -9,6 +9,7 @@ const propsToDisplay = [
'accessibilityLabelledBy',
'accessibilityRole',
'accessibilityViewIsModal',
'alt',
'aria-busy',
'aria-checked',
'aria-disabled',
+11 -1
View File
@@ -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 {
<View>
<Text testID="text">Hello</Text>
<TextInput testID="textInput" />
<Image testID="image" />
<Switch testID="switch" />
<ScrollView testID="scrollView" />
<Modal testID="modal" />
@@ -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.
+2 -2
View File
@@ -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(
+1 -1
View File
@@ -17,7 +17,7 @@ export function getTextInputValue(element: ReactTestInstance) {
return (
element.props.value ??
nativeState?.elementValues.get(element) ??
nativeState.valueForElement.get(element) ??
element.props.defaultValue ??
''
);
+108 -6
View File
@@ -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(
<>
<Switch testID="checked" value={true} />
<Switch testID="unchecked" value={false} />
<Switch testID="default" />
</>,
);
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:
<RCTSwitch
accessibilityRole="switch"
testID="checked"
value={true}
/>"
`);
expect(() => expect(unchecked).toBeChecked()).toThrowErrorMatchingInlineSnapshot(`
"expect(element).toBeChecked()
Received element is not checked:
<RCTSwitch
accessibilityRole="switch"
testID="unchecked"
value={false}
/>"
`);
expect(() => expect(defaultView).toBeChecked()).toThrowErrorMatchingInlineSnapshot(`
"expect(element).toBeChecked()
Received element is not checked:
<RCTSwitch
accessibilityRole="switch"
testID="default"
value={false}
/>"
`);
});
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:
<View
accessibilityRole="switch"
accessibilityState={
{
"checked": true,
}
}
accessible={true}
testID="switch-checked"
/>"
`);
expect(() => expect(unchecked).toBeChecked()).toThrowErrorMatchingInlineSnapshot(`
"expect(element).toBeChecked()
Received element is not checked:
<View
accessibilityRole="switch"
accessibilityState={
{
"checked": false,
}
}
accessible={true}
testID="switch-unchecked"
/>"
`);
expect(() => expect(defaultView).toBeChecked()).toThrowErrorMatchingInlineSnapshot(`
"expect(element).toBeChecked()
Received element is not checked:
<View
accessibilityRole="switch"
accessible={true}
testID="switch-default"
/>"
`);
});
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."`,
);
});
@@ -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 testID="view">Text</Text>);
const element = screen.getByTestId('view');
expect(element).toHaveAccessibleName('Text');
expect(element).not.toHaveAccessibleName('Other text');
});
test('toHaveAccessibleName() handles Image with "alt" prop', () => {
render(<Image testID="image" alt="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(<View testID="view" accessibilityLabel="Test label" />);
const element = screen.getByTestId('view');
const element = screen.getByTestId('view');
expect(element).toHaveAccessibleName();
expect(() => expect(element).not.toHaveAccessibleName()).toThrowErrorMatchingInlineSnapshot(`
"expect(element).not.toHaveAccessibleName()
+11 -5
View File
@@ -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];
}
+7 -12
View File
@@ -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<ReactTestInstance, string>;
valueForElement: WeakMap<ReactTestInstance, string>;
contentOffsetForElement: WeakMap<ReactTestInstance, Point>;
};
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(),
};
+7 -7
View File
@@ -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
<Text
accessibilityHint="hidden"
@@ -133,7 +133,7 @@ test('error message renders the element tree, preserving only helpful props', as
render(<Pressable accessibilityHint="HINT" key="3" />);
expect(() => screen.getByHintText('FOO')).toThrowErrorMatchingInlineSnapshot(`
"Unable to find an element with accessibilityHint: FOO
"Unable to find an element with accessibility hint: FOO
<View
accessibilityHint="HINT"
@@ -142,7 +142,7 @@ test('error message renders the element tree, preserving only helpful props', as
`);
expect(() => screen.getAllByHintText('FOO')).toThrowErrorMatchingInlineSnapshot(`
"Unable to find an element with accessibilityHint: FOO
"Unable to find an element with accessibility hint: FOO
<View
accessibilityHint="HINT"
@@ -151,7 +151,7 @@ test('error message renders the element tree, preserving only helpful props', as
`);
await expect(screen.findByHintText('FOO')).rejects.toThrowErrorMatchingInlineSnapshot(`
"Unable to find an element with accessibilityHint: FOO
"Unable to find an element with accessibility hint: FOO
<View
accessibilityHint="HINT"
@@ -160,7 +160,7 @@ test('error message renders the element tree, preserving only helpful props', as
`);
await expect(screen.findAllByHintText('FOO')).rejects.toThrowErrorMatchingInlineSnapshot(`
"Unable to find an element with accessibilityHint: FOO
"Unable to find an element with accessibility hint: FOO
<View
accessibilityHint="HINT"
+13 -1
View File
@@ -1,5 +1,5 @@
import * as React from 'react';
import { View, Text, TextInput, Pressable } from 'react-native';
import { View, Text, TextInput, Image, Pressable } from 'react-native';
import { render, screen } from '../..';
const BUTTON_LABEL = 'cool button';
@@ -220,6 +220,18 @@ test('getByLabelText supports nested aria-labelledby', () => {
expect(screen.getByLabelText(/nested text label/i)).toBe(screen.getByTestId('text-input'));
});
test('getByLabelText supports "Image"" with "alt" prop', () => {
render(
<>
<Image alt="Image Label" testID="image" />
</>,
);
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(<Pressable accessibilityLabel="LABEL" key="3" />);
+4 -4
View File
@@ -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
<Text
accessibilityRole="adjustable"
@@ -100,7 +100,7 @@ describe('accessibility value', () => {
`);
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
<Text
accessibilityRole="adjustable"
@@ -123,7 +123,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
<Text
accessibilityRole="adjustable"
@@ -146,7 +146,7 @@ describe('accessibility value', () => {
`);
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
<Text
accessibilityRole="adjustable"
+45 -16
View File
@@ -4,9 +4,11 @@ import {
Pressable,
Text,
TextInput,
Image,
TouchableOpacity,
TouchableWithoutFeedback,
View,
Switch,
} from 'react-native';
import { render, screen } from '../..';
@@ -16,11 +18,11 @@ const TEXT_LABEL = 'cool text';
const NO_MATCHES_TEXT: any = 'not-existent-element';
const getMultipleInstancesFoundMessage = (value: string) => {
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(
<>
<Image testID="image1" role="img" alt="an elephant" />
<Image testID="image2" accessibilityRole="image" alt="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(
<TouchableOpacity accessibilityRole="checkbox" accessibilityState={{ checked: 'mixed' }} />,
);
@@ -508,6 +529,14 @@ describe('supports accessibility states', () => {
expect(screen.queryByRole('checkbox', { checked: false })).toBe(null);
});
test('supports "Switch" component', () => {
render(<Switch value={true} />);
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(<View accessible role="checkbox" aria-checked={true} />);
expect(screen.getByRole('checkbox', { checked: true })).toBeTruthy();
@@ -759,7 +788,7 @@ describe('error messages', () => {
render(<View />);
expect(() => screen.getByRole('button')).toThrowErrorMatchingInlineSnapshot(`
"Unable to find an element with role: "button"
"Unable to find an element with role: button
<View />"
`);
@@ -769,7 +798,7 @@ describe('error messages', () => {
render(<View />);
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
<View />"
`);
@@ -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
<View />"
`);
@@ -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
<View />"
`);
@@ -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
<View />"
`);
@@ -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
<View />"
`);
@@ -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/
<View />"
`);
@@ -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
<View
accessibilityRole="button"
@@ -880,7 +909,7 @@ describe('matches only accessible elements', () => {
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(
<View accessibilityRole="menu">
<Text>Action</Text>
@@ -894,7 +923,7 @@ test('error message renders the element tree, preserving only helpful props', as
render(<View accessibilityRole="button" key="3" />);
expect(() => screen.getByRole('link')).toThrowErrorMatchingInlineSnapshot(`
"Unable to find an element with role: "link"
"Unable to find an element with role: link
<View
accessibilityRole="button"
@@ -902,7 +931,7 @@ test('error message renders the element tree, preserving only helpful props', as
`);
expect(() => screen.getAllByRole('link')).toThrowErrorMatchingInlineSnapshot(`
"Unable to find an element with role: "link"
"Unable to find an element with role: link
<View
accessibilityRole="button"
@@ -910,7 +939,7 @@ test('error message renders the element tree, preserving only helpful props', as
`);
await expect(screen.findByRole('link')).rejects.toThrowErrorMatchingInlineSnapshot(`
"Unable to find an element with role: "link"
"Unable to find an element with role: link
<View
accessibilityRole="button"
@@ -918,7 +947,7 @@ test('error message renders the element tree, preserving only helpful props', as
`);
await expect(screen.findAllByRole('link')).rejects.toThrowErrorMatchingInlineSnapshot(`
"Unable to find an element with role: "link"
"Unable to find an element with role: link
<View
accessibilityRole="button"
+2 -2
View File
@@ -31,9 +31,9 @@ const queryAllByHintText = (
};
const getMultipleError = (hint: TextMatch) =>
`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,
+5 -3
View File
@@ -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<ByRoleMatcher, ByRoleOptions> =>
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) => {
-2
View File
@@ -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<any>;
@@ -127,7 +126,6 @@ function buildRenderResult(
});
setRenderResult(result);
initNativeState();
return result;
}
+16
View File
@@ -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> = T | (string & {});
+4 -17
View File
@@ -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: {
+2 -2
View File
@@ -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 },
+1 -1
View File
@@ -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);
@@ -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();
+11 -17
View File
@@ -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) {
-12
View File
@@ -1,12 +0,0 @@
import { ReactTestInstance } from 'react-test-renderer';
import { ContentOffset } from '../event-builder/scroll-view';
const scrollOffsetForElement = new WeakMap<ReactTestInstance, ContentOffset>();
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);
}
+4 -4
View File
@@ -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<ContentOffset>,
initialOffset: ContentOffset,
target: Partial<Point>,
initialOffset: Point,
interpolator: InterpolatorFn,
): ContentOffset[] {
): Point[] {
if (target.y != null) {
return interpolator(target.y, initialOffset.y, DEFAULT_STEPS_COUNT).map((y) => ({
y,
+1 -1
View File
@@ -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);
+2 -6
View File
@@ -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));
@@ -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
+2 -2
View File
@@ -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.
:::
@@ -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).
@@ -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