packages/testUtils: renamed dir to test-utils

This commit is contained in:
Patrik Oldsberg
2020-04-07 16:31:08 +02:00
parent cc4d4a403e
commit 29094608c2
11 changed files with 0 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
registry=https://registry.npmjs.org/
+22
View File
@@ -0,0 +1,22 @@
# @backstage/test-utils
This package provides utilities that can be used to test plugins and apps for Backstage.
## Installation
Install the package via npm or yarn:
```sh
$ npm install --save-dev @backstage/test-utils
```
or
```sh
$ yarn add -D @backstage/test-utils
```
## Documentation
- [Backstage Readme](https://github.com/spotify/backstage/blob/master/README.md)
- [Backstage Documentation](https://github.com/spotify/backstage/blob/master/docs/README.md)
+51
View File
@@ -0,0 +1,51 @@
{
"name": "@backstage/test-utils",
"description": "Utilities to test Backstage plugins and apps.",
"version": "0.1.1-alpha.2",
"private": false,
"publishConfig": {
"access": "public"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/spotify/backstage",
"directory": "packages/testUtils"
},
"keywords": [
"backstage"
],
"license": "Apache-2.0",
"main": "dist/index.cjs.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "backstage-cli plugin:build",
"lint": "backstage-cli lint",
"test": "backstage-cli test"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.2",
"@backstage/theme": "^0.1.1-alpha.2",
"@material-ui/core": "^4.9.1",
"@testing-library/jest-dom": "^4.2.4",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^7.1.2",
"@types/jest": "^24.0.0",
"@types/node": "^12.0.0",
"react": "^16.12.0",
"react-dom": "^16.12.0",
"react-router": "^5.1.2",
"react-router-dom": "^5.1.2"
},
"peerDependencies": {
"@backstage/theme": "^0.1.1-alpha.2",
"@material-ui/core": "^4.9.1",
"@testing-library/jest-dom": "^4.2.4",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^7.1.2",
"react": "^16.12.0",
"react-dom": "^16.12.0",
"react-router": "^5.1.2",
"react-router-dom": "^5.1.2"
}
}
+16
View File
@@ -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';
+17
View File
@@ -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;
},
};
}
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.json",
"include": ["src"]
}