Skip to content

Commit 5ae2346

Browse files
Aria pattern utils (#6208)
* rough progress in refactoring api and testing timers * update RAC select tests to use the select util * fix edge cases and add flows for different interaction patterns * adding sections and removing valueElement, wrapping up final conversions for Picker tests only modified some of the Picker tests to leverage the utils where I deemed appropriate. Mainly where selection/opening of the Picker wasnt the main focus of the test or if the test itself wasnt checking stuff about the item nodes themselves. * add initial table util scaffolding mostly from old PR, adapted to fit the new api * testing against the RAC tests/React 16/17 had to add an increased test timeout for React 16 tests * more testing of long press, add findCell/findRow * test all the different interaction types the keyboard sort change util function isnt working properly, investigating. The focus isnt being restored to the right place * use click for now for some keyboard operations focus management and certain elements arent working properly, see comments * fix keyboard interaction for sort column util * figure out the proper timeout for realTimers * lint * fix docs and build point * forgot comma in rebase * Update packages/@react-spectrum/table/test/Table.test.js * go with factory instead of having the testers created in constructor this avoids possible issues with the same tester being used across multiple tests and getting in a weird state in parallel runs * fix select tests * replace jest expects with generic assertion this makes it so the test utils arent specific to jest * initial menu tester util * Fixing strict and using MenuTester in tests * fix failing tests * fix react 17 test * add combobox util * test combobox util * get rid of setText and add some additional utilities setText is a bit trouble some because we arent sure if the user wants to simulate a realistic typing flow and/or needs to perform a delete operation to clear the field so will leave that operation to them. * inital gridlist tester * refactor gridlist util * refactor combobox and menu * refactor select for consistency * refactor table for consistency * add selected row getter for looking at tests in quarry * have user provide callback for advancing timers this means we can handle real timers vs fake timers without needing to rely on jest or handling detection ourselves * update createTester so we get proper return types when creating a tester * fix lint and react 19 test * fix tsstrict and tests * review comments * make useLongPress accept mouse or touch * refactor so setting root element is done in contructor * refactor combobox tests so they dont destructure * refactor the rest of the utils to avoid destruct * support passing in interaction type directly when calling interaction * update api as per feedback and remove unnecessary warning * cleanup and lower test timeout to minimum to work in react 16 * fix strict and revert jest timing build failed with 15000 timeout which passed locally... --------- Co-authored-by: Robert Snow <[email protected]>
1 parent f606623 commit 5ae2346

File tree

22 files changed

+2025
-642
lines changed

22 files changed

+2025
-642
lines changed

jest.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,7 @@ module.exports = {
170170
'/node_modules/',
171171
'\\.ssr\\.test\\.[tj]sx?$'
172172
],
173+
testTimeout: 20000,
173174

174175
// The regexp pattern or array of patterns that Jest uses to detect test files
175176
// testRegex: [],
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
/*
2+
* Copyright 2024 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
13+
import {act, waitFor, within} from '@testing-library/react';
14+
import {BaseTesterOpts, UserOpts} from './user';
15+
16+
export interface ComboBoxOptions extends UserOpts, BaseTesterOpts {
17+
user: any,
18+
trigger?: HTMLElement
19+
}
20+
21+
export class ComboBoxTester {
22+
private user;
23+
private _interactionType: UserOpts['interactionType'];
24+
private _combobox: HTMLElement;
25+
private _trigger: HTMLElement | undefined;
26+
27+
constructor(opts: ComboBoxOptions) {
28+
let {root, trigger, user, interactionType} = opts;
29+
this.user = user;
30+
this._interactionType = interactionType || 'mouse';
31+
32+
// Handle case where element provided is a wrapper around the combobox. The expectation is that the user at least uses a ref/data attribute to
33+
// query their combobox/combobox wrapper (in the case of RSP) which they then pass to thhis
34+
this._combobox = root;
35+
let combobox = within(root).queryByRole('combobox');
36+
if (combobox) {
37+
this._combobox = combobox;
38+
}
39+
40+
// This is for if user need to directly set the trigger button element (aka the element provided in setElement was the combobox input or the trigger is somewhere unexpected)
41+
if (trigger) {
42+
this._trigger = trigger;
43+
} else {
44+
let trigger = within(root).queryByRole('button', {hidden: true});
45+
if (trigger) {
46+
this._trigger = trigger;
47+
} else {
48+
// For cases like https://www.w3.org/WAI/ARIA/apg/patterns/combobox/examples/combobox-select-only/ where the combobox
49+
// is also the trigger button
50+
this._trigger = this._combobox;
51+
}
52+
}
53+
}
54+
55+
setInteractionType = (type: UserOpts['interactionType']) => {
56+
this._interactionType = type;
57+
};
58+
59+
open = async (opts: {triggerBehavior?: 'focus' | 'manual', interactionType?: UserOpts['interactionType']} = {}) => {
60+
let {triggerBehavior = 'manual', interactionType = this._interactionType} = opts;
61+
let trigger = this.trigger;
62+
let combobox = this.combobox;
63+
let isDisabled = trigger!.hasAttribute('disabled');
64+
65+
if (interactionType === 'mouse') {
66+
if (triggerBehavior === 'focus') {
67+
await this.user.click(combobox);
68+
} else {
69+
await this.user.click(trigger);
70+
}
71+
} else if (interactionType === 'keyboard' && this._trigger != null) {
72+
act(() => this._trigger!.focus());
73+
if (triggerBehavior !== 'focus') {
74+
await this.user.keyboard('{ArrowDown}');
75+
}
76+
} else if (interactionType === 'touch') {
77+
if (triggerBehavior === 'focus') {
78+
await this.user.pointer({target: combobox, keys: '[TouchA]'});
79+
} else {
80+
await this.user.pointer({target: trigger, keys: '[TouchA]'});
81+
}
82+
}
83+
84+
await waitFor(() => {
85+
if (!isDisabled && combobox.getAttribute('aria-controls') == null) {
86+
throw new Error('No aria-controls found on combobox trigger element.');
87+
} else {
88+
return true;
89+
}
90+
});
91+
let listBoxId = combobox.getAttribute('aria-controls');
92+
await waitFor(() => {
93+
if (!isDisabled && (!listBoxId || document.getElementById(listBoxId) == null)) {
94+
throw new Error(`Listbox with id of ${listBoxId} not found in document.`);
95+
} else {
96+
return true;
97+
}
98+
});
99+
};
100+
101+
selectOption = async (opts: {option?: HTMLElement, optionText?: string, triggerBehavior?: 'focus' | 'manual', interactionType?: UserOpts['interactionType']} = {}) => {
102+
let {optionText, option, triggerBehavior, interactionType = this._interactionType} = opts;
103+
if (!this.combobox.getAttribute('aria-controls')) {
104+
await this.open({triggerBehavior});
105+
}
106+
107+
let listbox = this.listbox;
108+
if (listbox) {
109+
if (!option && optionText) {
110+
option = within(listbox).getByText(optionText);
111+
}
112+
113+
// TODO: keyboard method of selecting the the option is a bit tricky unless I simply simulate the user pressing the down arrow
114+
// the required amount of times to reach the option. For now just click the option even in keyboard mode
115+
if (interactionType === 'mouse' || interactionType === 'keyboard') {
116+
await this.user.click(option);
117+
} else {
118+
await this.user.pointer({target: option, keys: '[TouchA]'});
119+
}
120+
121+
if (option && option.getAttribute('href') == null) {
122+
await waitFor(() => {
123+
if (document.contains(listbox)) {
124+
throw new Error('Expected listbox element to not be in the document after selecting an option');
125+
} else {
126+
return true;
127+
}
128+
});
129+
}
130+
} else {
131+
throw new Error("Attempted to select a option in the combobox, but the listbox wasn't found.");
132+
}
133+
};
134+
135+
close = async () => {
136+
let listbox = this.listbox;
137+
if (listbox) {
138+
act(() => this.combobox.focus());
139+
await this.user.keyboard('[Escape]');
140+
141+
await waitFor(() => {
142+
if (document.contains(listbox)) {
143+
throw new Error('Expected listbox element to not be in the document after selecting an option');
144+
} else {
145+
return true;
146+
}
147+
});
148+
}
149+
};
150+
151+
get combobox() {
152+
return this._combobox;
153+
}
154+
155+
get trigger() {
156+
return this._trigger;
157+
}
158+
159+
get listbox() {
160+
let listBoxId = this.combobox.getAttribute('aria-controls');
161+
return listBoxId ? document.getElementById(listBoxId) || undefined : undefined;
162+
}
163+
164+
options = (opts: {element?: HTMLElement} = {}): HTMLElement[] | never[] => {
165+
let {element} = opts;
166+
element = element || this.listbox;
167+
let options = [];
168+
if (element) {
169+
options = within(element).queryAllByRole('option');
170+
}
171+
172+
return options;
173+
};
174+
175+
get sections() {
176+
let listbox = this.listbox;
177+
if (listbox) {
178+
return within(listbox).queryAllByRole('group');
179+
} else {
180+
return [];
181+
}
182+
}
183+
184+
get focusedOption() {
185+
let focusedOptionId = this.combobox.getAttribute('aria-activedescendant');
186+
return focusedOptionId ? document.getElementById(focusedOptionId) : undefined;
187+
}
188+
}

packages/@react-aria/test-utils/src/events.ts

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,18 +11,38 @@
1111
*/
1212

1313
import {act, fireEvent} from '@testing-library/react';
14+
import {UserOpts} from './user';
1415

1516
export const DEFAULT_LONG_PRESS_TIME = 500;
1617

1718
/**
1819
* Simulates a "long press" event on a element.
19-
* @param element - Element to long press.
20-
* @param opts - Options to pass to the simulated event. See https://testing-library.com/docs/dom-testing-library/api-events/#fireevent for more info.
20+
* @param opts - Options for the long press.
21+
* @param opts.element - Element to long press.
22+
* @param opts.advanceTimer - Function that when called advances the timers in your test suite by a specific amount of time(ms).
23+
* @param opts.pointeropts - Options to pass to the simulated event. Defaults to mouse. See https://testing-library.com/docs/dom-testing-library/api-events/#fireevent for more info.
2124
*/
22-
export function triggerLongPress(element: HTMLElement, opts = {}): void {
23-
fireEvent.pointerDown(element, {pointerType: 'touch', ...opts});
24-
act(() => {
25-
jest.advanceTimersByTime(DEFAULT_LONG_PRESS_TIME);
26-
});
27-
fireEvent.pointerUp(element, {pointerType: 'touch', ...opts});
25+
export async function triggerLongPress(opts: {element: HTMLElement, advanceTimer: (time?: number) => void | Promise<unknown>, pointerOpts?: {}}) {
26+
// TODO: note that this only works if the code from installPointerEvent is called somewhere in the test BEFORE the
27+
// render. Perhaps we should rely on the user setting that up since I'm not sure there is a great way to set that up here in the
28+
// util before first render. Will need to document it well
29+
let {element, advanceTimer, pointerOpts = {}} = opts;
30+
await fireEvent.pointerDown(element, {pointerType: 'mouse', ...pointerOpts});
31+
await act(async () => await advanceTimer(DEFAULT_LONG_PRESS_TIME));
32+
await fireEvent.pointerUp(element, {pointerType: 'mouse', ...pointerOpts});
33+
}
34+
35+
36+
export async function pressElement(user, element: HTMLElement, interactionType: UserOpts['interactionType']) {
37+
if (interactionType === 'mouse') {
38+
await user.click(element);
39+
} else if (interactionType === 'keyboard') {
40+
// TODO: For the keyboard flow, I wonder if it would be reasonable to just do fireEvent directly on the obtained row node or if we should
41+
// stick to simulting an actual user's keyboard operations as closely as possible
42+
// There are problems when using this approach though, actions like trying to trigger the select all checkbox and stuff behave oddly.
43+
act(() => element.focus());
44+
await user.keyboard('[Space]');
45+
} else if (interactionType === 'touch') {
46+
await user.pointer({target: element, keys: '[TouchA]'});
47+
}
2848
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
/*
2+
* Copyright 2024 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
13+
import {act, within} from '@testing-library/react';
14+
import {BaseTesterOpts, UserOpts} from './user';
15+
import {pressElement} from './events';
16+
17+
export interface GridListOptions extends UserOpts, BaseTesterOpts {
18+
user: any
19+
}
20+
export class GridListTester {
21+
private user;
22+
private _interactionType: UserOpts['interactionType'];
23+
private _gridlist: HTMLElement;
24+
25+
26+
constructor(opts: GridListOptions) {
27+
let {root, user, interactionType} = opts;
28+
this.user = user;
29+
this._interactionType = interactionType || 'mouse';
30+
this._gridlist = root;
31+
}
32+
33+
setInteractionType = (type: UserOpts['interactionType']) => {
34+
this._interactionType = type;
35+
};
36+
37+
// TODO: support long press? This is also pretty much the same as table's toggleRowSelection so maybe can share
38+
// For now, don't include long press, see if people need it or if we should just expose long press as a separate util if it isn't very common
39+
// If the current way of passing in the user specified advance timers is ok, then I'd be find including long press
40+
// Maybe also support an option to force the click to happen on a specific part of the element (checkbox or row). That way
41+
// the user can test a specific type of interaction?
42+
toggleRowSelection = async (opts: {index?: number, text?: string, interactionType?: UserOpts['interactionType']} = {}) => {
43+
let {index, text, interactionType = this._interactionType} = opts;
44+
45+
let row = this.findRow({index, text});
46+
let rowCheckbox = within(row).queryByRole('checkbox');
47+
if (rowCheckbox) {
48+
await pressElement(this.user, rowCheckbox, interactionType);
49+
} else {
50+
let cell = within(row).getAllByRole('gridcell')[0];
51+
await pressElement(this.user, cell, interactionType);
52+
}
53+
};
54+
55+
// TODO: pretty much the same as table except it uses this.gridlist. Make common between the two by accepting an option for
56+
// an element?
57+
findRow = (opts: {index?: number, text?: string}) => {
58+
let {
59+
index,
60+
text
61+
} = opts;
62+
63+
let row;
64+
if (index != null) {
65+
row = this.rows[index];
66+
} else if (text != null) {
67+
row = within(this?.gridlist).getByText(text);
68+
while (row && row.getAttribute('role') !== 'row') {
69+
row = row.parentElement;
70+
}
71+
}
72+
73+
return row;
74+
};
75+
76+
// TODO: There is a more difficult use case where the row has/behaves as link, don't think we have a good way to determine that unless the
77+
// user specificlly tells us
78+
triggerRowAction = async (opts: {index?: number, text?: string, needsDoubleClick?: boolean, interactionType?: UserOpts['interactionType']}) => {
79+
let {
80+
index,
81+
text,
82+
needsDoubleClick,
83+
interactionType = this._interactionType
84+
} = opts;
85+
86+
let row = this.findRow({index, text});
87+
if (row) {
88+
if (needsDoubleClick) {
89+
await this.user.dblClick(row);
90+
} else if (interactionType === 'keyboard') {
91+
act(() => row.focus());
92+
await this.user.keyboard('[Enter]');
93+
} else {
94+
await pressElement(this.user, row, interactionType);
95+
}
96+
}
97+
};
98+
99+
// TODO: do we really need this getter? Theoretically the user already has the reference to the gridlist
100+
get gridlist() {
101+
return this._gridlist;
102+
}
103+
104+
get rows() {
105+
return within(this?.gridlist).queryAllByRole('row');
106+
}
107+
108+
get selectedRows() {
109+
return this.rows.filter(row => row.getAttribute('aria-selected') === 'true');
110+
}
111+
112+
cells = (opts: {element?: HTMLElement} = {}) => {
113+
let {element} = opts;
114+
return within(element || this.gridlist).queryAllByRole('gridcell');
115+
};
116+
}

packages/@react-aria/test-utils/src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13-
export * from './events';
13+
export {triggerLongPress} from './events';
1414
export * from './testSetup';
1515
export * from './userEventMaps';
16+
export * from './user';

0 commit comments

Comments
 (0)