Skip to content
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

feat: Reusable tests #7011

Merged
merged 21 commits into from
Oct 24, 2024
Merged
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
4 changes: 4 additions & 0 deletions __mocks__/svg.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export default function SvgrURL() {
return <svg><g></g></svg>;
};
export const ReactComponent = (props) => <svg {...props} />;
4 changes: 3 additions & 1 deletion jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,9 @@ module.exports = {

// A map from regular expressions to module names that allow to stub out resources with a single module
moduleNameMapper: {
'\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': '<rootDir>/__mocks__/fileMock.js',
'^bundle-text:.*\\.svg$': '<rootDir>/__mocks__/fileMock.js',
'\\.svg$': '<rootDir>/__mocks__/svg.js',
'\\.(jpg|jpeg|png|gif|eot|otf|webp|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': '<rootDir>/__mocks__/fileMock.js',
'\\.(css|styl)$': 'identity-obj-proxy'
},

Expand Down
2 changes: 1 addition & 1 deletion packages/@react-aria/test-utils/src/combobox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {act, waitFor, within} from '@testing-library/react';
import {BaseTesterOpts, UserOpts} from './user';

export interface ComboBoxOptions extends UserOpts, BaseTesterOpts {
user: any,
user?: any,
trigger?: HTMLElement
}

Expand Down
2 changes: 1 addition & 1 deletion packages/@react-aria/test-utils/src/gridlist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {BaseTesterOpts, UserOpts} from './user';
import {pressElement} from './events';

export interface GridListOptions extends UserOpts, BaseTesterOpts {
user: any
user?: any
}
export class GridListTester {
private user;
Expand Down
101 changes: 82 additions & 19 deletions packages/@react-aria/test-utils/src/menu.ts
snowystinger marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,18 @@ import {BaseTesterOpts, UserOpts} from './user';
import {triggerLongPress} from './events';

export interface MenuOptions extends UserOpts, BaseTesterOpts {
user: any
user?: any,
isSubmenu?: boolean
}
export class MenuTester {
private user;
private _interactionType: UserOpts['interactionType'];
private _advanceTimer: UserOpts['advanceTimer'];
private _trigger: HTMLElement;
private _trigger: HTMLElement | undefined;
private _isSubmenu: boolean = false;

constructor(opts: MenuOptions) {
let {root, user, interactionType, advanceTimer} = opts;
let {root, user, interactionType, advanceTimer, isSubmenu} = opts;
this.user = user;
this._interactionType = interactionType || 'mouse';
this._advanceTimer = advanceTimer;
Expand All @@ -41,6 +43,8 @@ export class MenuTester {
this._trigger = root;
}
}

this._isSubmenu = isSubmenu || false;
}

setInteractionType = (type: UserOpts['interactionType']) => {
Expand All @@ -49,12 +53,12 @@ export class MenuTester {

// TODO: this has been common to select as well, maybe make select use it? Or make a generic method. Will need to make error messages generic
// One difference will be that it supports long press as well
open = async (opts: {needsLongPress?: boolean, interactionType?: UserOpts['interactionType']} = {}) => {
open = async (opts: {needsLongPress?: boolean, interactionType?: UserOpts['interactionType'], direction?: 'up' | 'down'} = {}) => {
let {
needsLongPress,
interactionType = this._interactionType
interactionType = this._interactionType,
direction
} = opts;

let trigger = this.trigger;
let isDisabled = trigger.hasAttribute('disabled');
if (interactionType === 'mouse' || interactionType === 'touch') {
Expand All @@ -70,8 +74,16 @@ export class MenuTester {
await this.user.pointer({target: trigger, keys: '[TouchA]'});
}
} else if (interactionType === 'keyboard' && !isDisabled) {
act(() => trigger.focus());
await this.user.keyboard('[Enter]');
if (direction === 'up') {
act(() => trigger.focus());
await this.user.keyboard('[ArrowUp]');
} else if (direction === 'down') {
act(() => trigger.focus());
await this.user.keyboard('[ArrowDown]');
} else {
act(() => trigger.focus());
await this.user.keyboard('[Enter]');
}
}

await waitFor(() => {
Expand All @@ -95,42 +107,57 @@ export class MenuTester {

// TODO: also very similar to select, barring potential long press support
// Close on select is also kinda specific?
selectOption = async (opts: {option?: HTMLElement, optionText?: string, menuSelectionMode?: 'single' | 'multiple', needsLongPress?: boolean, closesOnSelect?: boolean, interactionType?: UserOpts['interactionType']}) => {
selectOption = async (opts: {
option?: HTMLElement,
optionText?: string,
menuSelectionMode?: 'single' | 'multiple',
needsLongPress?: boolean,
closesOnSelect?: boolean,
interactionType?: UserOpts['interactionType'],
keyboardActivation?: 'Space' | 'Enter'
}) => {
let {
optionText,
menuSelectionMode = 'single',
needsLongPress,
closesOnSelect = true,
option,
interactionType = this._interactionType
interactionType = this._interactionType,
keyboardActivation = 'Enter'
} = opts;
let trigger = this.trigger;
if (!trigger.getAttribute('aria-controls')) {

if (!trigger.getAttribute('aria-controls') && !trigger.hasAttribute('aria-expanded')) {
await this.open({needsLongPress});
}

let menu = this.menu;
if (menu) {
if (!option && optionText) {
option = within(menu).getByText(optionText);
// @ts-ignore
option = (within(menu!).getByText(optionText).closest('[role=menuitem], [role=menuitemradio], [role=menuitemcheckbox]'))!;
}
if (!option) {
throw new Error('No option found in the menu.');
}

if (interactionType === 'keyboard') {
if (document.activeElement !== menu || !menu.contains(document.activeElement)) {
act(() => menu.focus());
}

await this.user.keyboard(optionText);
await this.user.keyboard('[Enter]');
await this.keyboardNavigateToOption({option});
await this.user.keyboard(`[${keyboardActivation}]`);
} else {
if (interactionType === 'mouse') {
await this.user.click(option);
} else {
await this.user.pointer({target: option, keys: '[TouchA]'});
}
}
act(() => {jest.runAllTimers();});

if (option && option.getAttribute('href') == null && option.getAttribute('aria-haspopup') == null && menuSelectionMode === 'single' && closesOnSelect) {
if (option && option.getAttribute('href') == null && option.getAttribute('aria-haspopup') == null && menuSelectionMode === 'single' && closesOnSelect && keyboardActivation !== 'Space' && !this._isSubmenu) {
await waitFor(() => {
if (document.activeElement !== trigger) {
throw new Error(`Expected the document.activeElement after selecting an option to be the menu trigger but got ${document.activeElement}`);
Expand All @@ -156,6 +183,7 @@ export class MenuTester {
needsLongPress,
interactionType = this._interactionType
} = opts;

let trigger = this.trigger;
let isDisabled = trigger.hasAttribute('disabled');
if (!trigger.getAttribute('aria-controls') && !isDisabled) {
Expand All @@ -171,8 +199,18 @@ export class MenuTester {
submenu = within(menu).getByText(submenuTriggerText);
}

let submenuTriggerTester = new MenuTester({user: this.user, interactionType: interactionType, root: submenu});
await submenuTriggerTester.open();
let submenuTriggerTester = new MenuTester({user: this.user, interactionType: this._interactionType, root: submenu, isSubmenu: true});
if (interactionType === 'mouse') {
await this.user.pointer({target: submenu});
act(() => {jest.runAllTimers();});
} else if (interactionType === 'keyboard') {
await this.keyboardNavigateToOption({option: submenu});
await this.user.keyboard('[ArrowRight]');
act(() => {jest.runAllTimers();});
} else {
await submenuTriggerTester.open();
}


return submenuTriggerTester;
}
Expand All @@ -181,6 +219,28 @@ export class MenuTester {
return null;
};

keyboardNavigateToOption = async (opts: {option: HTMLElement}) => {
snowystinger marked this conversation as resolved.
Show resolved Hide resolved
let {option} = opts;
let options = this.options;
let targetIndex = options.indexOf(option);
if (targetIndex === -1) {
throw new Error('Option provided is not in the menu');
}
if (document.activeElement === this.menu) {
await this.user.keyboard('[ArrowDown]');
}
let currIndex = options.indexOf(document.activeElement as HTMLElement);
if (targetIndex === -1) {
throw new Error('ActiveElement is not in the menu');
}
let direction = targetIndex > currIndex ? 'down' : 'up';

for (let i = 0; i < Math.abs(targetIndex - currIndex); i++) {
await this.user.keyboard(`[${direction === 'down' ? 'ArrowDown' : 'ArrowUp'}]`);
}
};


close = async () => {
let menu = this.menu;
if (menu) {
Expand All @@ -202,6 +262,9 @@ export class MenuTester {
};

get trigger() {
if (!this._trigger) {
throw new Error('No trigger element found for menu.');
}
return this._trigger;
}

Expand All @@ -210,9 +273,9 @@ export class MenuTester {
return menuId ? document.getElementById(menuId) : undefined;
}

get options(): HTMLElement[] | never[] {
get options(): HTMLElement[] {
let menu = this.menu;
let options = [];
let options: HTMLElement[] = [];
if (menu) {
options = within(menu).queryAllByRole('menuitem');
if (options.length === 0) {
Expand Down
2 changes: 1 addition & 1 deletion packages/@react-aria/test-utils/src/select.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {BaseTesterOpts, UserOpts} from './user';

export interface SelectOptions extends UserOpts, BaseTesterOpts {
// TODO: I think the type grabbed from the testing library dist for UserEvent is breaking the build, will need to figure out a better place to grab from
user: any
user?: any
}
export class SelectTester {
private user;
Expand Down
2 changes: 1 addition & 1 deletion packages/@react-aria/test-utils/src/table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {act, fireEvent, waitFor, within} from '@testing-library/react';
import {BaseTesterOpts, UserOpts} from './user';
import {pressElement, triggerLongPress} from './events';
export interface TableOptions extends UserOpts, BaseTesterOpts {
user: any,
user?: any,
advanceTimer: UserOpts['advanceTimer']
}

Expand Down
2 changes: 1 addition & 1 deletion packages/@react-aria/test-utils/src/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,6 @@ export class User {
}

createTester<T extends PatternNames>(patternName: T, opts: ObjectOptionsTypes<T>): ObjectType<T> {
return new (keyToUtil)[patternName]({...opts, user: this.user, interactionType: this.interactionType, advanceTimer: this.advanceTimer}) as ObjectType<T>;
return new (keyToUtil)[patternName]({user: this.user, interactionType: this.interactionType, advanceTimer: this.advanceTimer, ...opts}) as ObjectType<T>;
}
}
Loading