Skip to content

feat: add support for alt text queries #1663

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/__tests__/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ test('resetToDefaults() resets internal config to defaults', () => {
switch: 'A',
scrollView: 'A',
modal: 'A',
image: 'A',
},
});
expect(getConfig().hostComponentNames).toEqual({
Expand All @@ -44,6 +45,7 @@ test('resetToDefaults() resets internal config to defaults', () => {
switch: 'A',
scrollView: 'A',
modal: 'A',
image: 'A',
});

resetToDefaults();
Expand Down
6 changes: 6 additions & 0 deletions src/__tests__/host-component-names.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ describe('getHostComponentNames', () => {
switch: 'banana',
scrollView: 'banana',
modal: 'banana',
image: 'banana',
},
});

Expand All @@ -26,6 +27,7 @@ describe('getHostComponentNames', () => {
switch: 'banana',
scrollView: 'banana',
modal: 'banana',
image: 'banana',
});
});

Expand All @@ -40,6 +42,7 @@ describe('getHostComponentNames', () => {
switch: 'RCTSwitch',
scrollView: 'RCTScrollView',
modal: 'Modal',
image: 'Image',
});
expect(getConfig().hostComponentNames).toBe(hostComponentNames);
});
Expand Down Expand Up @@ -70,6 +73,7 @@ describe('configureHostComponentNamesIfNeeded', () => {
switch: 'RCTSwitch',
scrollView: 'RCTScrollView',
modal: 'Modal',
image: 'Image',
});
});

Expand All @@ -81,6 +85,7 @@ describe('configureHostComponentNamesIfNeeded', () => {
switch: 'banana',
scrollView: 'banana',
modal: 'banana',
image: 'banana',
},
});

Expand All @@ -92,6 +97,7 @@ describe('configureHostComponentNamesIfNeeded', () => {
switch: 'banana',
scrollView: 'banana',
modal: 'banana',
image: 'banana',
});
});

Expand Down
1 change: 1 addition & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export type HostComponentNames = {
switch: string;
scrollView: string;
modal: string;
image: string;
};

export type InternalConfig = Config & {
Expand Down
1 change: 1 addition & 0 deletions src/helpers/format-default.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const propsToDisplay = [
'accessibilityLabelledBy',
'accessibilityRole',
'accessibilityViewIsModal',
'alt',
'aria-busy',
'aria-checked',
'aria-disabled',
Expand Down
12 changes: 11 additions & 1 deletion src/helpers/host-component-names.tsx
Original file line number Diff line number Diff line change
@@ -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 { Modal, ScrollView, Switch, Image, Text, TextInput, View } from 'react-native';
import { configureInternal, getConfig, HostComponentNames } from '../config';
import { renderWithAct } from '../render-act';
import { HostTestInstance } from './component-tree';
Expand Down Expand Up @@ -37,6 +37,7 @@ function detectHostComponentNames(): HostComponentNames {
<Switch testID="switch" />
<ScrollView testID="scrollView" />
<Modal testID="modal" />
<Image testID="image" />
</View>,
);

Expand All @@ -46,6 +47,7 @@ function detectHostComponentNames(): HostComponentNames {
switch: getByTestId(renderer.root, 'switch').type as string,
scrollView: getByTestId(renderer.root, 'scrollView').type as string,
modal: getByTestId(renderer.root, 'modal').type as string,
image: getByTestId(renderer.root, 'image').type as string,
};
} catch (error) {
const errorMessage =
Expand Down Expand Up @@ -108,3 +110,11 @@ export function isHostScrollView(element?: ReactTestInstance | null): element is
export function isHostModal(element?: ReactTestInstance | null): element is HostTestInstance {
return element?.type === getHostComponentNames().modal;
}

/**
* 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;
}
150 changes: 150 additions & 0 deletions src/queries/__tests__/alt-text.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import React from 'react';
import { Image, View } from 'react-native';
import { render, screen } from '../..';

const Banana = () => (
<View>
<Image alt="Image of a fresh banana" />
<Image alt="Image of a brown banana" />
</View>
);

test('it can locate an image by alt text', () => {
render(
<View>
<Image alt="alt text" />
<Image alt="other text" />
</View>,
);

expect(screen.getByAltText('alt text')).toBeTruthy();
expect(screen.getByAltText('other text')).toBeTruthy();

expect(screen.getAllByAltText('alt text')).toHaveLength(1);
expect(screen.getAllByAltText('other text')).toHaveLength(1);
});

test('supports a regex matcher', () => {
render(
<View>
<Image alt="alt text" />
<Image alt="other text" />
</View>,
);

expect(screen.getByAltText(/alt/)).toBeTruthy();
expect(screen.getAllByAltText(/alt/)).toHaveLength(1);
expect(screen.getAllByAltText(/text/)).toHaveLength(2);
});

test('getByAltText, queryByAltText', () => {
render(<Banana />);
const component = screen.getByAltText(/fresh banana/);

expect(() => screen.getByAltText('InExistent')).toThrow(
'Unable to find an element with alt text: InExistent',
);

expect(screen.getByAltText(/fresh banana/)).toBe(component);
expect(screen.queryByAltText('InExistent')).toBeNull();

expect(() => screen.getByAltText(/banana/)).toThrow(
'Found multiple elements with alt text: /banana/',
);
expect(() => screen.queryByAltText(/banana/)).toThrow(
'Found multiple elements with alt text: /banana/',
);
});

test('getAllByAltText, queryAllByAltText', () => {
render(<Banana />);
const imageElements = screen.getAllByAltText(/banana/);

expect(imageElements.length).toBe(2);
expect(imageElements[0].props.alt).toBe('Image of a fresh banana');
expect(imageElements[1].props.alt).toBe('Image of a brown banana');

const queriedImageElements = screen.queryAllByAltText(/banana/);

expect(queriedImageElements.length).toBe(2);
expect(queriedImageElements[0]).toBe(imageElements[0]);
expect(queriedImageElements[1]).toBe(imageElements[1]);
});

test('findByAltText and findAllByAltText work asynchronously', async () => {
const options = { timeout: 10 }; // Short timeout so that this test runs quickly
render(<View />);
await expect(screen.findByAltText('alt text', {}, options)).rejects.toBeTruthy();
await expect(screen.findAllByAltText('alt text', {}, options)).rejects.toBeTruthy();

setTimeout(
() =>
screen.rerender(
<View>
<Image alt="alt text" />
</View>,
),
20,
);

await expect(screen.findByAltText('alt text')).resolves.toBeTruthy();
await expect(screen.findAllByAltText('alt text')).resolves.toHaveLength(1);
}, 20000);

test('byAltText queries support hidden option', () => {
render(<Image style={{ display: 'none' }} alt="hidden" />);

expect(screen.getByAltText('hidden', { includeHiddenElements: true })).toBeTruthy();

expect(screen.queryByAltText('hidden')).toBeFalsy();
expect(screen.queryByAltText('hidden', { includeHiddenElements: false })).toBeFalsy();
expect(() => screen.getByAltText('hidden', { includeHiddenElements: false }))
.toThrowErrorMatchingInlineSnapshot(`
"Unable to find an element with alt text: hidden

<Image
alt="hidden"
style={
{
"display": "none",
}
}
/>"
`);
});

test('error message renders the element tree, preserving only helpful props', async () => {
render(<Image alt="alt text" key="3" />);

expect(() => screen.getByAltText('FOO')).toThrowErrorMatchingInlineSnapshot(`
"Unable to find an element with alt text: FOO

<Image
alt="alt text"
/>"
`);

expect(() => screen.getAllByAltText('FOO')).toThrowErrorMatchingInlineSnapshot(`
"Unable to find an element with alt text: FOO

<Image
alt="alt text"
/>"
`);

await expect(screen.findByAltText('FOO')).rejects.toThrowErrorMatchingInlineSnapshot(`
"Unable to find an element with alt text: FOO

<Image
alt="alt text"
/>"
`);

await expect(screen.findAllByAltText('FOO')).rejects.toThrowErrorMatchingInlineSnapshot(`
"Unable to find an element with alt text: FOO

<Image
alt="alt text"
/>"
`);
});
69 changes: 69 additions & 0 deletions src/queries/alt-text.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import type { ReactTestInstance } from 'react-test-renderer';
import { findAll } from '../helpers/find-all';
import { isHostImage } from '../helpers/host-component-names';
import { matches, TextMatch, TextMatchOptions } from '../matches';
import { makeQueries } from './make-queries';
import type {
FindAllByQuery,
FindByQuery,
GetAllByQuery,
GetByQuery,
QueryAllByQuery,
QueryByQuery,
} from './make-queries';
import type { CommonQueryOptions } from './options';

type ByAltTextOptions = CommonQueryOptions & TextMatchOptions;

export function matchAltText(
element: ReactTestInstance,
expectedAltText: TextMatch,
options: TextMatchOptions = {},
) {
const altText = element.props.alt;
if (altText == null) return false;

const { normalizer, exact } = options;

return matches(expectedAltText, altText, normalizer, exact);
}

const queryAllByAltText = (
instance: ReactTestInstance,
): QueryAllByQuery<TextMatch, ByAltTextOptions> =>
function queryAllByAltTextFn(text, options = {}) {
return findAll(instance, (node) => isHostImage(node) && matchAltText(node, text, options), {
...options,
matchDeepestOnly: true,
});
};

const getMultipleError = (text: TextMatch) =>
`Found multiple elements with alt text: ${String(text)}`;

const getMissingError = (text: TextMatch) =>
`Unable to find an element with alt text: ${String(text)}`;

const { getBy, getAllBy, queryBy, queryAllBy, findBy, findAllBy } = makeQueries(
queryAllByAltText,
getMissingError,
getMultipleError,
);

export type ByAltTextQueries = {
getByAltText: GetByQuery<TextMatch, ByAltTextOptions>;
getAllByAltText: GetAllByQuery<TextMatch, ByAltTextOptions>;
queryByAltText: QueryByQuery<TextMatch, ByAltTextOptions>;
queryAllByAltText: QueryAllByQuery<TextMatch, ByAltTextOptions>;
findByAltText: FindByQuery<TextMatch, ByAltTextOptions>;
findAllByAltText: FindAllByQuery<TextMatch, ByAltTextOptions>;
};

export const bindByAltTextQueries = (instance: ReactTestInstance): ByAltTextQueries => ({
getByAltText: getBy(instance),
getAllByAltText: getAllBy(instance),
queryByAltText: queryBy(instance),
queryAllByAltText: queryAllBy(instance),
findByAltText: findBy(instance),
findAllByAltText: findAllBy(instance),
});
6 changes: 6 additions & 0 deletions src/screen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,12 @@ const defaultScreen: Screen = {
queryAllByText: notImplemented,
findByText: notImplemented,
findAllByText: notImplemented,
getByAltText: notImplemented,
getAllByAltText: notImplemented,
queryByAltText: notImplemented,
queryAllByAltText: notImplemented,
findByAltText: notImplemented,
findAllByAltText: notImplemented,
};

export let screen: Screen = defaultScreen;
Expand Down
2 changes: 2 additions & 0 deletions src/within.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { bindByHintTextQueries } from './queries/hint-text';
import { bindByRoleQueries } from './queries/role';
import { bindByA11yStateQueries } from './queries/accessibility-state';
import { bindByA11yValueQueries } from './queries/accessibility-value';
import { bindByAltTextQueries } from './queries/alt-text';
import { bindUnsafeByTypeQueries } from './queries/unsafe-type';
import { bindUnsafeByPropsQueries } from './queries/unsafe-props';

Expand All @@ -22,6 +23,7 @@ export function within(instance: ReactTestInstance) {
...bindByRoleQueries(instance),
...bindByA11yStateQueries(instance),
...bindByA11yValueQueries(instance),
...bindByAltTextQueries(instance),
...bindUnsafeByTypeQueries(instance),
...bindUnsafeByPropsQueries(instance),
};
Expand Down