From 9240e92e91df635b35ad08d4aba131349aad8478 Mon Sep 17 00:00:00 2001 From: David Buchan-Swanson Date: Tue, 10 Sep 2024 11:56:14 +1000 Subject: [PATCH] feat: add support for alt text queries --- src/__tests__/config.test.ts | 2 + src/__tests__/host-component-names.test.tsx | 6 + src/config.ts | 1 + src/helpers/format-default.ts | 1 + src/helpers/host-component-names.tsx | 12 +- src/queries/__tests__/alt-text.test.tsx | 150 ++++++++++++++++++++ src/queries/alt-text.ts | 69 +++++++++ src/screen.ts | 6 + src/within.ts | 2 + 9 files changed, 248 insertions(+), 1 deletion(-) create mode 100644 src/queries/__tests__/alt-text.test.tsx create mode 100644 src/queries/alt-text.ts diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index 47cdc6627..693fc8d27 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -36,6 +36,7 @@ test('resetToDefaults() resets internal config to defaults', () => { switch: 'A', scrollView: 'A', modal: 'A', + image: 'A', }, }); expect(getConfig().hostComponentNames).toEqual({ @@ -44,6 +45,7 @@ test('resetToDefaults() resets internal config to defaults', () => { switch: 'A', scrollView: 'A', modal: 'A', + image: 'A', }); resetToDefaults(); diff --git a/src/__tests__/host-component-names.test.tsx b/src/__tests__/host-component-names.test.tsx index ca526742d..533c679ec 100644 --- a/src/__tests__/host-component-names.test.tsx +++ b/src/__tests__/host-component-names.test.tsx @@ -17,6 +17,7 @@ describe('getHostComponentNames', () => { switch: 'banana', scrollView: 'banana', modal: 'banana', + image: 'banana', }, }); @@ -26,6 +27,7 @@ describe('getHostComponentNames', () => { switch: 'banana', scrollView: 'banana', modal: 'banana', + image: 'banana', }); }); @@ -40,6 +42,7 @@ describe('getHostComponentNames', () => { switch: 'RCTSwitch', scrollView: 'RCTScrollView', modal: 'Modal', + image: 'Image', }); expect(getConfig().hostComponentNames).toBe(hostComponentNames); }); @@ -70,6 +73,7 @@ describe('configureHostComponentNamesIfNeeded', () => { switch: 'RCTSwitch', scrollView: 'RCTScrollView', modal: 'Modal', + image: 'Image', }); }); @@ -81,6 +85,7 @@ describe('configureHostComponentNamesIfNeeded', () => { switch: 'banana', scrollView: 'banana', modal: 'banana', + image: 'banana', }, }); @@ -92,6 +97,7 @@ describe('configureHostComponentNamesIfNeeded', () => { switch: 'banana', scrollView: 'banana', modal: 'banana', + image: 'banana', }); }); diff --git a/src/config.ts b/src/config.ts index fd867a895..75b7eb665 100644 --- a/src/config.ts +++ b/src/config.ts @@ -26,6 +26,7 @@ export type HostComponentNames = { switch: string; scrollView: string; modal: string; + image: string; }; export type InternalConfig = Config & { diff --git a/src/helpers/format-default.ts b/src/helpers/format-default.ts index 223140753..7ee42b5b1 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 cd4b52239..f806e392c 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 { 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'; @@ -37,6 +37,7 @@ function detectHostComponentNames(): HostComponentNames { + , ); @@ -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 = @@ -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; +} diff --git a/src/queries/__tests__/alt-text.test.tsx b/src/queries/__tests__/alt-text.test.tsx new file mode 100644 index 000000000..d1baeb6b6 --- /dev/null +++ b/src/queries/__tests__/alt-text.test.tsx @@ -0,0 +1,150 @@ +import React from 'react'; +import { Image, View } from 'react-native'; +import { render, screen } from '../..'; + +const Banana = () => ( + + Image of a fresh banana + Image of a brown banana + +); + +test('it can locate an image by alt text', () => { + render( + + alt text + other text + , + ); + + 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( + + alt text + other text + , + ); + + expect(screen.getByAltText(/alt/)).toBeTruthy(); + expect(screen.getAllByAltText(/alt/)).toHaveLength(1); + expect(screen.getAllByAltText(/text/)).toHaveLength(2); +}); + +test('getByAltText, queryByAltText', () => { + render(); + 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(); + 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(); + await expect(screen.findByAltText('alt text', {}, options)).rejects.toBeTruthy(); + await expect(screen.findAllByAltText('alt text', {}, options)).rejects.toBeTruthy(); + + setTimeout( + () => + screen.rerender( + + alt text + , + ), + 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(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 + + hidden" + `); +}); + +test('error message renders the element tree, preserving only helpful props', async () => { + render(alt text); + + expect(() => screen.getByAltText('FOO')).toThrowErrorMatchingInlineSnapshot(` + "Unable to find an element with alt text: FOO + + alt text" + `); + + expect(() => screen.getAllByAltText('FOO')).toThrowErrorMatchingInlineSnapshot(` + "Unable to find an element with alt text: FOO + + alt text" + `); + + await expect(screen.findByAltText('FOO')).rejects.toThrowErrorMatchingInlineSnapshot(` + "Unable to find an element with alt text: FOO + + alt text" + `); + + await expect(screen.findAllByAltText('FOO')).rejects.toThrowErrorMatchingInlineSnapshot(` + "Unable to find an element with alt text: FOO + + alt text" + `); +}); diff --git a/src/queries/alt-text.ts b/src/queries/alt-text.ts new file mode 100644 index 000000000..654892771 --- /dev/null +++ b/src/queries/alt-text.ts @@ -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 => + 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; + getAllByAltText: GetAllByQuery; + queryByAltText: QueryByQuery; + queryAllByAltText: QueryAllByQuery; + findByAltText: FindByQuery; + findAllByAltText: FindAllByQuery; +}; + +export const bindByAltTextQueries = (instance: ReactTestInstance): ByAltTextQueries => ({ + getByAltText: getBy(instance), + getAllByAltText: getAllBy(instance), + queryByAltText: queryBy(instance), + queryAllByAltText: queryAllBy(instance), + findByAltText: findBy(instance), + findAllByAltText: findAllBy(instance), +}); diff --git a/src/screen.ts b/src/screen.ts index 1fcbd3e2a..81f03cf72 100644 --- a/src/screen.ts +++ b/src/screen.ts @@ -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; diff --git a/src/within.ts b/src/within.ts index 59db8bf1a..8c64482bf 100644 --- a/src/within.ts +++ b/src/within.ts @@ -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'; @@ -22,6 +23,7 @@ export function within(instance: ReactTestInstance) { ...bindByRoleQueries(instance), ...bindByA11yStateQueries(instance), ...bindByA11yValueQueries(instance), + ...bindByAltTextQueries(instance), ...bindUnsafeByTypeQueries(instance), ...bindUnsafeByPropsQueries(instance), };