packages/testUtils: renamed dir to test-utils
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
export * from './testUtils';
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import '@testing-library/jest-dom/extend-expect';
|
||||
@@ -0,0 +1,221 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { act, fireEvent } from '@testing-library/react';
|
||||
|
||||
const codes = {
|
||||
Tab: 9,
|
||||
Enter: 10,
|
||||
Click: 17 /* This keyboard can click, deal with it */,
|
||||
Esc: 27,
|
||||
};
|
||||
|
||||
export default class Keyboard {
|
||||
static async type(target, input) {
|
||||
await new Keyboard(target).type(input);
|
||||
}
|
||||
|
||||
static async typeDebug(target, input) {
|
||||
await new Keyboard(target, { debug: true }).type(input);
|
||||
}
|
||||
|
||||
static toReadableInput(chars) {
|
||||
return chars.split('').map(char => {
|
||||
switch (char.charCodeAt(0)) {
|
||||
case codes.Tab:
|
||||
return '<Tab>';
|
||||
case codes.Enter:
|
||||
return '<Enter>';
|
||||
case codes.Click:
|
||||
return '<Click>';
|
||||
case codes.Esc:
|
||||
return '<Esc>';
|
||||
default:
|
||||
return char;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static fromReadableInput(input) {
|
||||
return input.trim().replace(/\s*<([a-zA-Z]+)>\s*/g, (match, name) => {
|
||||
if (name in codes) {
|
||||
return String.fromCharCode(codes[name]);
|
||||
}
|
||||
throw new Error(`Unknown char name: '${name}'`);
|
||||
});
|
||||
}
|
||||
|
||||
constructor(target, { debug = false } = {}) {
|
||||
this.debug = debug;
|
||||
|
||||
if (target.ownerDocument) {
|
||||
this.document = target.ownerDocument;
|
||||
} else if (target.baseElement) {
|
||||
this.document = target.baseElement.ownerDocument;
|
||||
} else {
|
||||
throw new TypeError(
|
||||
'Keyboard(target): target must be DOM node or react-testing-library render() output',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
toString() {
|
||||
return `Keyboard{document=${this.document}, debug=${this.debug}}`;
|
||||
}
|
||||
|
||||
_log(message, ...args) {
|
||||
if (this.debug) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[Keyboard] ${message}`, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
_pretty(element) {
|
||||
const attrs = [...element.attributes]
|
||||
.map(attr => `${attr.name}="${attr.value}"`)
|
||||
.join(' ');
|
||||
return `<${element.nodeName.toLowerCase()} ${attrs}>`;
|
||||
}
|
||||
|
||||
get focused() {
|
||||
return this.document.activeElement;
|
||||
}
|
||||
|
||||
async type(input) {
|
||||
this._log(
|
||||
`sending sequence '${input}' with initial focus ${this._pretty(
|
||||
this.focused,
|
||||
)}`,
|
||||
);
|
||||
await this.send(Keyboard.fromReadableInput(input));
|
||||
}
|
||||
|
||||
async send(chars) {
|
||||
for (const key of chars.split('')) {
|
||||
const charCode = key.charCodeAt(0);
|
||||
|
||||
if (charCode === codes.Tab) {
|
||||
await this.tab();
|
||||
continue;
|
||||
}
|
||||
|
||||
const focused = this.focused;
|
||||
if (!focused || focused === this.document.body) {
|
||||
throw Error(
|
||||
`No element focused in document while trying to type '${Keyboard.toReadableInput(
|
||||
chars,
|
||||
)}'`,
|
||||
);
|
||||
}
|
||||
const nextValue = (focused.value || '') + key;
|
||||
|
||||
if (charCode >= 32) {
|
||||
await this._sendKey(key, charCode, () => {
|
||||
this._log(
|
||||
`sending +${key} = '${nextValue}' to ${this._pretty(focused)}`,
|
||||
);
|
||||
fireEvent.change(focused, {
|
||||
target: { value: nextValue },
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
});
|
||||
} else if (charCode === codes.Enter) {
|
||||
await this.enter(focused.value || '');
|
||||
} else if (charCode === codes.Esc) {
|
||||
await this.escape();
|
||||
} else if (charCode === codes.Click) {
|
||||
await this.click();
|
||||
} else {
|
||||
throw new Error(`Unsupported char code, ${charCode}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async click() {
|
||||
this._log(`clicking ${this._pretty(this.focused)}`);
|
||||
await act(async () => fireEvent.click(this.focused));
|
||||
}
|
||||
|
||||
async tab() {
|
||||
await this._sendKey('Tab', codes.Tab, () => {
|
||||
const focusable = this.document.querySelectorAll(
|
||||
[
|
||||
'a[href]',
|
||||
'area[href]',
|
||||
'input:not([disabled])',
|
||||
'select:not([disabled])',
|
||||
'textarea:not([disabled])',
|
||||
'button:not([disabled])',
|
||||
'iframe',
|
||||
'object',
|
||||
'embed',
|
||||
'*[tabindex]',
|
||||
'*[contenteditable]',
|
||||
].join(','),
|
||||
);
|
||||
|
||||
const tabbable = [...focusable].filter(el => {
|
||||
return el.tabIndex >= 0;
|
||||
});
|
||||
|
||||
const focused = this.document.activeElement;
|
||||
const focusedIndex = tabbable.indexOf(focused);
|
||||
const nextFocus = tabbable[focusedIndex + (1 % tabbable.length)];
|
||||
|
||||
this._log(
|
||||
`tabbing to ${this._pretty(nextFocus)} ${this.focused.textContent}`,
|
||||
);
|
||||
nextFocus.focus();
|
||||
});
|
||||
}
|
||||
|
||||
async enter(value) {
|
||||
this._log(`submitting '${value}' via ${this._pretty(this.focused)}`);
|
||||
await act(() =>
|
||||
this._sendKey('Enter', codes.Enter, () => {
|
||||
if (this.focused.type === 'button') {
|
||||
fireEvent.click(this.focused, { target: { value } });
|
||||
} else {
|
||||
fireEvent.submit(this.focused, {
|
||||
target: { value },
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async escape() {
|
||||
this._log(`escape from ${this._pretty(this.focused)}`);
|
||||
await act(async () => this._sendKey('Escape', codes.Esc));
|
||||
}
|
||||
|
||||
async _sendKey(key, charCode, action) {
|
||||
const event = { key, charCode, keyCode: charCode, which: charCode };
|
||||
const focused = this.focused;
|
||||
|
||||
if (fireEvent.keyDown(focused, event)) {
|
||||
if (fireEvent.keyPress(focused, event)) {
|
||||
if (action) {
|
||||
action();
|
||||
}
|
||||
}
|
||||
}
|
||||
fireEvent.keyUp(focused, event);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import Keyboard from './Keyboard';
|
||||
import { render } from '@testing-library/react';
|
||||
|
||||
describe('testUtils.Keyboard', () => {
|
||||
it('types into some inputs with focus and submits a form', async () => {
|
||||
const typed1 = [];
|
||||
const typed2 = [];
|
||||
const typed3 = [];
|
||||
|
||||
let submitted = false;
|
||||
const handleSubmit = event => {
|
||||
event.preventDefault();
|
||||
submitted = true;
|
||||
};
|
||||
|
||||
const rendered = render(
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input onChange={({ target: { value } }) => typed1.push(value)} />
|
||||
<input
|
||||
onChange={({ target: { value } }) => typed2.push(value)}
|
||||
/* eslint-disable-next-line jsx-a11y/no-autofocus */
|
||||
autoFocus
|
||||
/>
|
||||
<input onChange={({ target: { value } }) => typed3.push(value)} />
|
||||
</form>,
|
||||
);
|
||||
|
||||
const keyboard = new Keyboard(rendered);
|
||||
await keyboard.send('xy');
|
||||
await keyboard.tab();
|
||||
await keyboard.send('abc');
|
||||
await keyboard.enter();
|
||||
|
||||
expect(typed1).toEqual([]);
|
||||
expect(typed2).toEqual(['x', 'xy']);
|
||||
expect(typed3).toEqual(['a', 'ab', 'abc']);
|
||||
expect(submitted).toBe(true);
|
||||
});
|
||||
|
||||
it('can use Keyboard.type to send readable input', async () => {
|
||||
const typed1 = [];
|
||||
const typed2 = [];
|
||||
const typed3 = [];
|
||||
|
||||
let submitted = false;
|
||||
const handleSubmit = event => {
|
||||
event.preventDefault();
|
||||
submitted = true;
|
||||
};
|
||||
|
||||
const rendered = render(
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input
|
||||
defaultValue="1"
|
||||
onChange={({ target: { value } }) => typed1.push(value)}
|
||||
/>
|
||||
<input
|
||||
defaultValue="2"
|
||||
onChange={({ target: { value } }) => typed2.push(value)}
|
||||
/>
|
||||
<input
|
||||
defaultValue="3"
|
||||
onChange={({ target: { value } }) => typed3.push(value)}
|
||||
/>
|
||||
</form>,
|
||||
);
|
||||
|
||||
await Keyboard.type(rendered, '<Tab> a <Tab> b <Tab> c <Enter>');
|
||||
|
||||
expect(typed1).toEqual(['1a']);
|
||||
expect(typed2).toEqual(['2b']);
|
||||
expect(typed3).toEqual(['3c']);
|
||||
expect(submitted).toBe(true);
|
||||
});
|
||||
|
||||
it('should be able to navigate a radio input with click', async () => {
|
||||
const selections = [];
|
||||
|
||||
const rendered = render(
|
||||
<div onChange={({ target: { value } }) => selections.push(value)}>
|
||||
<input type="radio" name="group" value="a" />
|
||||
<input type="radio" name="group" value="b" />
|
||||
<input type="radio" name="group" value="c" />
|
||||
</div>,
|
||||
);
|
||||
|
||||
await Keyboard.type(rendered, '<Tab> <Click> <Tab> <Tab> <Click>');
|
||||
|
||||
expect(selections).toEqual(['a', 'c']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, {
|
||||
ComponentType,
|
||||
ReactNode,
|
||||
FunctionComponent,
|
||||
ReactElement,
|
||||
} from 'react';
|
||||
import { ThemeProvider } from '@material-ui/core';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { render, RenderResult } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router';
|
||||
import { Route } from 'react-router-dom';
|
||||
import { BackstageTheme } from '@backstage/theme';
|
||||
|
||||
export { default as Keyboard } from './Keyboard';
|
||||
export { default as mockBreakpoint } from './mockBreakpoint';
|
||||
export * from './logCollector';
|
||||
|
||||
export function wrapInTestApp(
|
||||
Component: ComponentType | ReactNode,
|
||||
initialRouterEntries: string[] = ['/'],
|
||||
) {
|
||||
let Wrapper: ComponentType;
|
||||
if (Component instanceof Function) {
|
||||
Wrapper = Component;
|
||||
} else {
|
||||
Wrapper = (() => Component) as FunctionComponent;
|
||||
}
|
||||
|
||||
return (
|
||||
<MemoryRouter initialEntries={initialRouterEntries}>
|
||||
<Route component={Wrapper} />
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
export function wrapInThemedTestApp(
|
||||
component: ReactNode,
|
||||
initialRouterEntries: string[] = ['/'],
|
||||
) {
|
||||
const themed = (
|
||||
<ThemeProvider theme={BackstageTheme}>{component}</ThemeProvider>
|
||||
);
|
||||
return wrapInTestApp(themed, initialRouterEntries);
|
||||
}
|
||||
|
||||
export const wrapInTheme = (component: ReactNode, theme = BackstageTheme) => (
|
||||
<ThemeProvider theme={theme}>{component}</ThemeProvider>
|
||||
);
|
||||
|
||||
// Components using useEffect to perform an asynchronous action (such as fetch) must be rendered within an async
|
||||
// act call to properly get the final state, even with mocked responses. This utility method makes the signature a bit
|
||||
// cleaner, since act doesn't return the result of the evaluated function.
|
||||
// https://github.com/testing-library/react-testing-library/issues/281
|
||||
// https://github.com/facebook/react/pull/14853
|
||||
export async function renderWithEffects(
|
||||
nodes: ReactElement,
|
||||
): Promise<RenderResult> {
|
||||
let value: RenderResult;
|
||||
await act(() => {
|
||||
value = render(nodes);
|
||||
});
|
||||
// @ts-ignore
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/* eslint-disable no-console */
|
||||
/* eslint-disable no-param-reassign */
|
||||
|
||||
// If the callback function is async this one will be too.
|
||||
export function withLogCollector(logsToCollect, callback) {
|
||||
if (typeof logsToCollect === 'function') {
|
||||
callback = logsToCollect;
|
||||
logsToCollect = ['log', 'warn', 'error'];
|
||||
}
|
||||
const logs = {
|
||||
log: [],
|
||||
warn: [],
|
||||
error: [],
|
||||
};
|
||||
|
||||
const origLog = console.log;
|
||||
const origWarn = console.warn;
|
||||
const origError = console.error;
|
||||
|
||||
if (logsToCollect.includes('log')) {
|
||||
console.log = message => {
|
||||
logs.log.push(message);
|
||||
};
|
||||
}
|
||||
if (logsToCollect.includes('warn')) {
|
||||
console.warn = message => {
|
||||
logs.warn.push(message);
|
||||
};
|
||||
}
|
||||
if (logsToCollect.includes('error')) {
|
||||
console.error = message => {
|
||||
logs.error.push(message);
|
||||
};
|
||||
}
|
||||
|
||||
const restore = () => {
|
||||
console.log = origLog;
|
||||
console.warn = origWarn;
|
||||
console.error = origError;
|
||||
};
|
||||
|
||||
try {
|
||||
const ret = callback();
|
||||
|
||||
if (!ret || !ret.then) {
|
||||
restore();
|
||||
return logs;
|
||||
}
|
||||
|
||||
return ret.then(
|
||||
() => {
|
||||
restore();
|
||||
return logs;
|
||||
},
|
||||
error => {
|
||||
restore();
|
||||
throw error;
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
restore();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { act } from '@testing-library/react';
|
||||
|
||||
type Breakpoint = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
|
||||
const queryToBreakpoint = {
|
||||
'(min-width:1920px)': 'xl',
|
||||
'(min-width:1280px)': 'lg',
|
||||
'(min-width:960px)': 'md',
|
||||
'(min-width:600px)': 'sm',
|
||||
'(min-width:0px)': 'xs',
|
||||
} as Record<string, Breakpoint>;
|
||||
|
||||
function toBreakpoint(query: string) {
|
||||
const breakpoint = queryToBreakpoint[query];
|
||||
if (!breakpoint) {
|
||||
throw new Error(
|
||||
`received unknown media query in breakpoint mock: '${query}'`,
|
||||
);
|
||||
}
|
||||
return breakpoint;
|
||||
}
|
||||
|
||||
type Listener = (event: { matches: boolean }) => void;
|
||||
|
||||
interface QueryList {
|
||||
addListener(listener: Listener): void;
|
||||
removeListener(listener: Listener): void;
|
||||
matches: boolean;
|
||||
}
|
||||
|
||||
interface Query {
|
||||
query: string;
|
||||
queryList: QueryList;
|
||||
listeners: Set<Listener>;
|
||||
}
|
||||
|
||||
export default function mockBreakpoint(initialBreakpoint: Breakpoint = 'xl') {
|
||||
let currentBreakpoint = initialBreakpoint;
|
||||
const queries = Array<Query>();
|
||||
|
||||
(window as any).matchMedia = (query: string): QueryList => {
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
const queryList: QueryList = {
|
||||
addListener(listener) {
|
||||
listeners.add(listener);
|
||||
},
|
||||
removeListener(listener) {
|
||||
listeners.delete(listener);
|
||||
},
|
||||
matches: toBreakpoint(query) === currentBreakpoint,
|
||||
};
|
||||
|
||||
queries.push({ query, queryList, listeners });
|
||||
|
||||
return queryList;
|
||||
};
|
||||
|
||||
return {
|
||||
set(breakpoint: Breakpoint) {
|
||||
currentBreakpoint = breakpoint;
|
||||
|
||||
act(() => {
|
||||
queries.forEach(({ query, queryList, listeners }) => {
|
||||
const matches = toBreakpoint(query) === breakpoint;
|
||||
queryList.matches = matches;
|
||||
listeners.forEach(listener => listener({ matches }));
|
||||
});
|
||||
});
|
||||
},
|
||||
remove() {
|
||||
delete window.matchMedia;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user