[Testbed utils] Readme + chrome extension (#35724)

This commit is contained in:
Sébastien Loix 2019-05-07 09:58:46 +02:00 committed by GitHub
parent 7711b652cd
commit 12b93bcdd1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 798 additions and 65 deletions

View file

@ -9,13 +9,14 @@ import { AutoFollowPatternAdd } from '../../../public/app/sections/auto_follow_p
import { ccrStore } from '../../../public/app/store';
import routing from '../../../public/app/services/routing';
const testBedOptions = {
const testBedConfig = {
store: ccrStore,
memoryRouter: {
onRouter: (router) => routing.reactRouter = router
}
};
const initTestBed = registerTestBed(AutoFollowPatternAdd, { options: testBedOptions, store: ccrStore });
const initTestBed = registerTestBed(AutoFollowPatternAdd, testBedConfig);
export const setup = (props) => {
const testBed = initTestBed(props);

View file

@ -11,7 +11,8 @@ import routing from '../../../public/app/services/routing';
import { AUTO_FOLLOW_PATTERN_EDIT_NAME } from './constants';
const testBedOptions = {
const testBedConfig = {
store: ccrStore,
memoryRouter: {
onRouter: (router) => routing.reactRouter = router,
// The auto-follow pattern id to fetch is read from the router ":id" param
@ -22,7 +23,7 @@ const testBedOptions = {
}
};
const initTestBed = registerTestBed(AutoFollowPatternEdit, { options: testBedOptions, store: ccrStore });
const initTestBed = registerTestBed(AutoFollowPatternEdit, testBedConfig);
export const setup = (props) => {
const testBed = initTestBed(props);

View file

@ -9,13 +9,14 @@ import { AutoFollowPatternList } from '../../../public/app/sections/home/auto_fo
import { ccrStore } from '../../../public/app/store';
import routing from '../../../public/app/services/routing';
const testBedOptions = {
const testBedConfig = {
store: ccrStore,
memoryRouter: {
onRouter: (router) => routing.reactRouter = router
}
};
const initTestBed = registerTestBed(AutoFollowPatternList, { options: testBedOptions, store: ccrStore });
const initTestBed = registerTestBed(AutoFollowPatternList, testBedConfig);
export const setup = (props) => {
const testBed = initTestBed(props);

View file

@ -9,13 +9,14 @@ import { FollowerIndexAdd } from '../../../public/app/sections/follower_index_ad
import { ccrStore } from '../../../public/app/store';
import routing from '../../../public/app/services/routing';
const testBedOptions = {
const testBedConfig = {
store: ccrStore,
memoryRouter: {
onRouter: (router) => routing.reactRouter = router
}
};
const initTestBed = registerTestBed(FollowerIndexAdd, { options: testBedOptions, store: ccrStore });
const initTestBed = registerTestBed(FollowerIndexAdd, testBedConfig);
export const setup = (props) => {
const testBed = initTestBed(props);

View file

@ -11,7 +11,8 @@ import routing from '../../../public/app/services/routing';
import { FOLLOWER_INDEX_EDIT_NAME } from './constants';
const testBedOptions = {
const testBedConfig = {
store: ccrStore,
memoryRouter: {
onRouter: (router) => routing.reactRouter = router,
// The follower index id to fetch is read from the router ":id" param
@ -22,7 +23,7 @@ const testBedOptions = {
}
};
const initTestBed = registerTestBed(FollowerIndexEdit, { options: testBedOptions, store: ccrStore });
const initTestBed = registerTestBed(FollowerIndexEdit, testBedConfig);
export const setup = (props) => {
const testBed = initTestBed(props);

View file

@ -9,13 +9,14 @@ import { FollowerIndicesList } from '../../../public/app/sections/home/follower_
import { ccrStore } from '../../../public/app/store';
import routing from '../../../public/app/services/routing';
const testBedOptions = {
const testBedConfig = {
store: ccrStore,
memoryRouter: {
onRouter: (router) => routing.reactRouter = router
}
};
const initTestBed = registerTestBed(FollowerIndicesList, { options: testBedOptions, store: ccrStore });
const initTestBed = registerTestBed(FollowerIndicesList, testBedConfig);
export const setup = (props) => {
const testBed = initTestBed(props);

View file

@ -10,7 +10,8 @@ import { ccrStore } from '../../../public/app/store';
import routing from '../../../public/app/services/routing';
import { BASE_PATH } from '../../../common/constants';
const testBedOptions = {
const testBedConfig = {
store: ccrStore,
memoryRouter: {
initialEntries: [`${BASE_PATH}/follower_indices`],
componentRoutePath: `${BASE_PATH}/:section`,
@ -18,4 +19,4 @@ const testBedOptions = {
}
};
export const setup = registerTestBed(CrossClusterReplicationHome, { options: testBedOptions, store: ccrStore });
export const setup = registerTestBed(CrossClusterReplicationHome, testBedConfig);

View file

@ -9,13 +9,14 @@ import { RemoteClusterAdd } from '../../../public/sections/remote_cluster_add';
import { createRemoteClustersStore } from '../../../public/store';
import { registerRouter } from '../../../public/services/routing';
const testBedOptions = {
const testBedConfig = {
store: createRemoteClustersStore,
memoryRouter: {
onRouter: (router) => registerRouter(router)
}
};
const initTestBed = registerTestBed(RemoteClusterAdd, { options: testBedOptions, store: createRemoteClustersStore });
const initTestBed = registerTestBed(RemoteClusterAdd, testBedConfig);
export const setup = (props) => {
const testBed = initTestBed(props);

View file

@ -11,7 +11,8 @@ import { registerRouter } from '../../../public/services/routing';
import { REMOTE_CLUSTER_EDIT_NAME } from './constants';
const testBedOptions = {
const testBedConfig = {
store: createRemoteClustersStore,
memoryRouter: {
onRouter: (router) => registerRouter(router),
// The remote cluster name to edit is read from the router ":id" param
@ -22,4 +23,4 @@ const testBedOptions = {
}
};
export const setup = registerTestBed(RemoteClusterEdit, { options: testBedOptions, store: createRemoteClustersStore });
export const setup = registerTestBed(RemoteClusterEdit, testBedConfig);

View file

@ -9,13 +9,14 @@ import { RemoteClusterList } from '../../../public/sections/remote_cluster_list'
import { createRemoteClustersStore } from '../../../public/store';
import { registerRouter } from '../../../public/services/routing';
const testBedOptions = {
const testBedConfig = {
store: createRemoteClustersStore,
memoryRouter: {
onRouter: (router) => registerRouter(router)
}
};
const initTestBed = registerTestBed(RemoteClusterList, { options: testBedOptions, store: createRemoteClustersStore });
const initTestBed = registerTestBed(RemoteClusterList, testBedConfig);
export const setup = (props) => {
const testBed = initTestBed(props);

View file

@ -10,7 +10,8 @@ import { registerRouter } from '../../../public/crud_app/services';
import { createRollupJobsStore } from '../../../public/crud_app/store';
import { JobList } from '../../../public/crud_app/sections/job_list';
const testBedOptions = {
const testBedConfig = {
store: createRollupJobsStore,
memoryRouter: {
onRouter: (router) => {
// register our react memory router
@ -19,4 +20,4 @@ const testBedOptions = {
}
};
export const setup = registerTestBed(JobList, { options: testBedOptions, store: createRollupJobsStore });
export const setup = registerTestBed(JobList, testBedConfig);

283
x-pack/test_utils/README.md Normal file
View file

@ -0,0 +1,283 @@
# Testbed utils
The Testbed is a small library to help testing React components. It is most useful when testing application "sections" (or pages) in **integration**
than when unit testing single components in isolation.
## Motivation
The Elasticsearch UI team built this to support client-side integration testing. When testing complete "pages" we get to test
our application in a way that is closer to how a user would interact with it in a browser. It also gives us more confidence in
our tests and avoids testing implementation details.
We test everything up to the HTTP Requests made from the client to the Node.js API server. Those requests need to be mocked. This means that with a good
**API integration test** coverage of those endpoints, we can reduce the functional tests to a minimum.
With this in mind, we needed a way to easily mount a component on a React Router `<Route>` (this component could possibily have _child_ routes and
need access to the browser URL parameters and query params). In order to solve that, the Testbed wraps the component around a `MemoryRouter`.
On the other side, the majority of our current applications use Redux as state management so we needed a simple way to wrap our component under test
inside a redux store provider.
## How to use it
At the top of your test file (you only need to declare it once), register a new Testbed by providing a React Component and an optional configuration object.
You receive in return a function that you need to call to mount the component in your test.
**Example 1**
```ts
// remote_clusters_list.helpers.ts
import { registerTestBed } from '../../../../test_utils';
import { RemoteClusterList } from '../../app/sections/remote_cluster_list';
import { remoteClustersStore } from '../../app/store';
import routing from '../../app/services/routing';
const config = {
memoryRouter: {
onRouter(router) {
routing.registerRouter(router); // send the router instance to the routing service
},
initialEntries: ['/some-resource-name'], // the Router initial URL
componentRoutePath: '/:name' // the Component <Route /> path
},
store: remoteClusterStore
};
export const setup = registerTestBed(RemoteClusterList, config);
```
Once you have registered a TestBed, you can call the `setup()` function to mount the component in your tests. You will get an object
back with a set of utility methods to test the component (refer to the documentation below for a complete list of the methods available).
```ts
// remote_cluster.test.ts
import { setup } from './remote_clusters_list.helpers.ts';
describe('<RemoteClusterList />', () => {
test('it should have a table with 3 rows', () => {
const { component, exists, find } = setup();
// component is an Enzyme reactWrapper
console.log(component.debug());
expect(exists('remoteClusterTable')).toBe(true);
expect(find('remoteClusterTable.row').length).toBe(3);
});
});
```
## Test subjects
The Testbed utils are meant to be used with test subjects. Test subjects are elements that are tagged specifically for selecting from tests. Use test subjects over CSS selectors when possible.
```html
<div id="container”>
<CustomButton id="clickMe” data-test-subj=”containerButton” />
</div>
```
```ts
find('containerButton').simulate('click');
```
If you need to access a CSS selector, target first the closest test subject.
```ts
const text = find('containerButton').find('.link--active').text();
```
## Typescript
If you use Typescript, you can provide a string union type for your test subjects and you will get **autocomplete** on the test subjects in your test. To automate finding all the subjects on the page, use the Chrome extension below.
```ts
type TestSubjects = 'indicesTable' | 'createIndexButton' | 'pageTitle';
export const setup = registerTestBed<TestSubjects>(MyComponent);
```
## Chrome extension
There is a small Chrome extension that you can install in order to track the test subject on the current page. As it is meant to be used
during development, the extension is only active when navigating a `localhost` URL.
You will find the "Test subjects finder" extension in the `x-pack/test_utils/chrome_extension` folder.
### Install the extension
- open the "extensions" window in Chrome
- activate the "Developer mode" (top right corner)
- drag and drop the `test_subjects_finder` folder on the window.
You can specify a DOM node (the tree "root") from which the test subjects will be found. If you don't specify any, the document `<body>` will be used. The output format can either be `Typescript` (to export a string union type) or `List`.
### Output
Once you start tracking the test subjects on the page, the output will be printed in the **Chrome dev console**.
## API
## `registerTestBed(Component [, testBedConfig])`
Instantiate a new TestBed to test a React component. The arguments it receives are
- `Component` (the React component to test)
- `testBedConfig` (optional). An optional Testbed configuration object.
**@returns** A function to instantiate and mount the component.
### `testBedConfig`
The `testBedConfig` has the following properties (all **optional**)
- `defaultProps` The default props to pass to the mounted component. Those props can be overriden when calling the `setup([props])` callback
- `memoryRouter` Configuration object for the react-router `MemoryRouter` with the following properties
- `wrapComponent` Flag to provide or not a `MemoryRouter`. If set to `false`, there won't be any router and the component won't be added on a `<Route />`. (default: `true`)
- `initialEntries` The React Router **initial entries** setting. (default: `['/']`. [see doc](https://github.com/ReactTraining/react-router/blob/master/packages/react-router/docs/api/MemoryRouter.md))
- `initialIndex` The React Router **initial index** setting (default: `0`)
- `componentRoutePath` The route **path** for the mounted component (default: `"/"`)
- `onRouter` A callback that will be called with the React Router instance when the component is mounted
- `store` A redux store. You can also provide a function that returns a store. (default: `null`)
## `setup([props])`
When registering a Testbed, you receive in return a setup function. This function accepts one optional argument:
- `props` (optional) Props to pass to the mounted component.
```js
describe('<RemoteClusterList />', () => {
test('it should be green', () => {
// Will mount <RemoteClusterList color="green" />
const { component } = setup({ color: 'green' });
...
});
});
```
**@returns** An object with the following **properties** and **helpers** for the testing
#### `component`
The mounted component. It is an enzyme **reactWrapper**.
#### `exists(testSubject)`
Pass it a `data-test-subj` and it will return true if it exists or false if it does not exist. You can provide a nested path to access the
test subject by separating the parent and child with a dot (e.g. `myForm.followerIndexName`).
```js
const { exists } = setup();
expect(exists('myTestSubject')).toBe(true);
```
#### `find(testDataSubject)`
Pass it a `data-test-subj` and it will return an Enzyme reactWrapper of the node. You can provide a nested path to access the
test subject by separating the parent and child with a dot (e.g. `myForm.followerIndexName`).
```js
const { find } = setup();
const someNode = find('myTestSubject');
expect(someNode.text()).toBe('hello');
```
#### `setProps(props)`
Update the props passed to a component.
**Important**: This method can only be used on a Component that is _not_ wrapped by a `<Route />`.
```js
...
const { setup } = registerTestBed(RemoteClusterList);
describe('<RemoteClusterList />', () => {
test('it should work', () => {
const { exists, find, setProps } = setup();
// test logic...
// update the props of the component
setProps({ color: 'yellow' });
...
});
});
```
#### `table`
An object with the following methods:
##### `getMetaData(testSubject)`
Parse an EUI table and return metadata information about its rows and columns. You can provide a nested path to access the
test subject by separating the parent and child with a dot (e.g. `mySection.myTable`). It returns an object with two properties:
- `tableCellsValues` a two dimensional array of rows + columns with the text content of each cell of the table
- `rows` an array of row objects. A row object has the following two properties:
- `reactWrapper` the Enzyme react wrapper of the `tr` element.
- `columns` an array of columns objects. A column object has two properties:
- `reactWrapper` the Enzyme react wrapper for the table row `td`
- `value` the text content of the table cell
```html
<table data-test-subj="myTable">
<tr>
<td>Row 0, column 0</td>
<td>Row 0, column 1</td>
</tr>
<tr>
<td>Row 1, column 0</td>
<td>Row 1, column 1</td>
</tr>
</table>
```
```js
const { table: { getMetaData } } = setup();
const { tableCellsValues } = getMetaData('myTable');
expect(tableCellsValues).toEqual([
['Row 0, column 0'], ['Row 0, column 1'],
['Row 1, column 0'], ['Row 1, column 1'],
]);
```
#### `form`
An object with the following methods:
##### `setInputValue(input, value, isAsync)`
Set the value of a form input. The input can either be a test subject (a string) or an Enzyme react wrapper. If you specify a test subject,
you can provide a nested path to access it by separating the parent and child with a dot (e.g. `myForm.followerIndexName`).
`isAsync`: flag that will return a Promise that resolves on the next "tick". This is useful if updating the input triggers
an async operation (like a HTTP request) and we need it to resolve so the DOM gets updated (default: `false`).
```js
await form.setInputValue('myInput', 'some value', true);
```
##### `selectCheckBox(testSubject)`
Select a form checkbox.
##### `toggleEuiSwitch(testSubject)`
Toggle an Eui Switch.
##### `setComboBoxValue(testSubject, value)`
The EUI `<EuiComboBox />` component is special in the sense that it needs the keyboard ENTER key to be pressed
in order to register the value provided. This helper takes care of that.
##### `getErrorsMessages()`
Find all the DOM nodes with the `.euiFormErrorText` css class from EUI and return an Array with its text content.

View file

@ -0,0 +1,25 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
/* eslint-disable no-undef */
chrome.runtime.onInstalled.addListener(function () {
chrome.storage.sync.set({ outputType: 'typescript' });
chrome.declarativeContent.onPageChanged.removeRules(undefined, () => {
// Only activate the plugin on localhost
chrome.declarativeContent.onPageChanged.addRules([{
conditions: [
new chrome.declarativeContent.PageStateMatcher({
pageUrl: { hostEquals: 'localhost' },
}),
new chrome.declarativeContent.PageStateMatcher({
pageUrl: { hostEquals: 'kibana-dev' },
})
],
actions: [new chrome.declarativeContent.ShowPageAction()]
}]);
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

View file

@ -0,0 +1,26 @@
{
"name": "Test subjects finder",
"version": "1.0",
"description": "Read and print the test subjects on the current page.",
"permissions": ["activeTab", "declarativeContent", "storage"],
"background": {
"scripts": ["background.js"],
"persistent": false
},
"page_action": {
"default_popup": "popup.html",
"default_icon": {
"16": "images/kibana16.png",
"32": "images/kibana32.png",
"48": "images/kibana48.png",
"128": "images/kibana128.png"
}
},
"icons": {
"16": "images/kibana16.png",
"32": "images/kibana32.png",
"48": "images/kibana48.png",
"128": "images/kibana128.png"
},
"manifest_version": 2
}

View file

@ -0,0 +1,40 @@
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<h1>Test subjects finder</h1>
<main>
<div class="track-config">
<div class="form-control">
<label class="form-control__label" for="domRootInput">Dom root</label>
<input class="form-control__input" type="text" id="domRootInput" placeholder="e.g. #my-app" />
<div class="form-control__helper-text">
The DOM node you want to start to traverse from. If not specified, the "body" will be used.
</div>
</div>
<div class="form-control">
<label class="form-control__label" for="outputTypeSelect">Output type</label>
<select class="form-control__select" id="outputTypeSelect">
<option value="typescript">Typescript</option>
<option value="list">List</option>
</select>
<div class="form-control__helper-text">
If you chose "typescript" you will get a Union Type ready to copy and paste in your test file.
</div>
</div>
</div>
<div class="form-actions">
<button id="startTrackingButton">Start tracking test subjects</button>
<button id="stopTrackingButton">Stop tracking test subjects</button>
</div>
</main>
<script src="popup.js"></script>
</body>
</html>

View file

@ -0,0 +1,96 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
/* eslint-disable no-undef */
// const trace = (message) => {
// chrome.tabs.executeScript(
// undefined,
// { code: `console.log("${message}")` },
// );
// };
const isTrackingTestSubjects = () => (
new Promise((resolve) => {
chrome.tabs.executeScript(
undefined,
{ code: '(() => Boolean(window.__test_utils__ && window.__test_utils__.isTracking))()' },
([result]) => {
resolve(result);
}
);
})
);
const onStartTracking = () => {
document.body.classList.add('is-tracking');
};
const onStopTracking = () => {
document.body.classList.remove('is-tracking');
};
chrome.storage.sync.get(['outputType', 'domTreeRoot'], async ({ outputType, domTreeRoot }) => {
const domRootInput = document.getElementById('domRootInput');
const outputTypeSelect = document.getElementById('outputTypeSelect');
const startTrackButton = document.getElementById('startTrackingButton');
const stopTrackButton = document.getElementById('stopTrackingButton');
const isTracking = await isTrackingTestSubjects();
// UI state
if (isTracking) {
document.body.classList.add('is-tracking');
} else {
document.body.classList.remove('is-tracking');
}
// FORM state
if (domTreeRoot) {
domRootInput.value = domTreeRoot;
}
document.querySelectorAll('#outputTypeSelect option').forEach((node) => {
if (node.value === outputType) {
node.setAttribute('selected', 'selected');
}
});
// FORM events
domRootInput.addEventListener('change', (e) => {
const { value } = e.target;
chrome.storage.sync.set({ domTreeRoot: value });
});
outputTypeSelect.addEventListener('change', (e) => {
const { value } = e.target;
chrome.storage.sync.set({ outputType: value });
});
startTrackButton.addEventListener('click', () => {
onStartTracking();
chrome.tabs.executeScript(
undefined,
{ file: 'start_tracking_test_subjects.js' },
);
});
stopTrackButton.addEventListener('click', () => {
onStopTracking();
chrome.tabs.executeScript(
undefined,
{ file: 'stop_tracking_test_subjects.js' },
);
});
});
chrome.runtime.onMessage.addListener((request) => {
if (request === 'TRACK_SUBJECTS_ERROR') {
onStopTracking();
}
});

View file

@ -0,0 +1,106 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
/* eslint-disable no-undef */
(function () {
chrome.storage.sync.get(['domTreeRoot', 'outputType'], ({ domTreeRoot, outputType }) => {
const datasetKey = 'testSubj';
if (domTreeRoot && !document.querySelector(domTreeRoot)) {
// Let our popup extension know about this...
chrome.runtime.sendMessage('TRACK_SUBJECTS_ERROR');
throw new Error(`DOM node "${domTreeRoot}" not found.`);
}
const dataTestSubjects = new Set();
const arrayToType = array => (
array.reduce((string, subject) => {
return string === '' ? `'${subject}'` : `${string}\n | '${subject}'`;
}, '')
);
const arrayToList = array => (
array.reduce((string, subject) => {
return string === '' ? `'${subject}'` : `${string}\n\'${subject}'`;
}, '')
);
const findTestSubjects = (
node = domTreeRoot ? document.querySelector(domTreeRoot) : document.querySelector('body'),
path = []
) => {
if (!node) {
// We probably navigated outside the initial DOM root
return;
}
const testSubjectOnNode = node.dataset[datasetKey];
if (testSubjectOnNode) {
dataTestSubjects.add(testSubjectOnNode);
}
const updatedPath = testSubjectOnNode
? [...path, testSubjectOnNode]
: path;
if (!node.children.length) {
const pathToString = updatedPath.join('.');
if (pathToString) {
dataTestSubjects.add(pathToString);
}
return;
}
for (let i = 0; i < node.children.length; i++) {
findTestSubjects(node.children[i], updatedPath);
}
};
const output = () => {
const allTestSubjects = Array.from(dataTestSubjects).sort();
console.log(`------------- TEST SUBJECTS (${allTestSubjects.length}) ------------- `);
const content = outputType === 'list'
? `${arrayToList(allTestSubjects)}`
: `export type TestSubjects = ${arrayToType(allTestSubjects)}`;
console.log(content);
};
// Handler for the clicks on the document to keep tracking
// new test subjects
const documentClicksHandler = () => {
const total = dataTestSubjects.size;
findTestSubjects();
if (dataTestSubjects.size === total) {
// No new test subject, nothing to output
return;
}
output();
};
// Add meta data on the window object
window.__test_utils__ = window.__test_utils__ || { documentClicksHandler, isTracking: false };
// Handle "click" event on the document to update our test subjects
if (!window.__test_utils__.isTracking) {
document.addEventListener('click', window.__test_utils__.documentClicksHandler);
window.__test_utils__.isTracking = true;
}
findTestSubjects();
output();
});
}());

View file

@ -0,0 +1,10 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
if (window.__test_utils__ && window.__test_utils__.isTracking) {
document.removeEventListener('click', window.__test_utils__.documentClicksHandler);
window.__test_utils__.isTracking = false;
}

View file

@ -0,0 +1,110 @@
* {
box-sizing: border-box;
}
body {
padding: 16px;
width: 300px;
}
h1 {
font-size: 1rem;
margin: 0 0 16px;
text-align: center;
}
.form-control {
margin-bottom: 16px;
}
.form-control__label {
display: block;
font-weight: 600;
margin-bottom: 4px;
}
.form-control__input {
width: 100%;
height: 40px;
background-color: #fbfcfd;
background-repeat: no-repeat;
background-size: 0% 100%;
box-shadow: 0 1px 1px -1px rgba(152, 162, 179, 0.2), 0 3px 2px -2px rgba(152, 162, 179, 0.2), inset 0 0 0 1px rgba(0, 0, 0, 0.1);
transition: background-color 150ms ease-in, background-image 150ms ease-in, background-size 150ms ease-in, -webkit-box-shadow 150ms ease-in;
transition: box-shadow 150ms ease-in, background-color 150ms ease-in, background-image 150ms ease-in, background-size 150ms ease-in;
font-family: "Inter UI", -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
font-weight: 400;
letter-spacing: -.005em;
-webkit-text-size-adjust: 100%;
-webkit-font-kerning: normal;
font-kerning: normal;
font-size: 14px;
line-height: 1em;
color: #343741;
border: none;
border-radius: 0;
padding: 12px;
margin-bottom: 4px;
}
.form-control__helper-text {
font-size: 0.7rem;
color: #666;
}
.form-control__select {
width: 100%;
padding: 4px;
margin-bottom: 4px;
}
.form-actions {
border-top: 1px solid #ddd;
padding-top: 24px;
text-align: center;
}
button {
font-family: "Inter UI", -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
font-weight: 400;
letter-spacing: -.005em;
-webkit-text-size-adjust: 100%;
-webkit-font-kerning: normal;
font-kerning: normal;
font-size: 1rem;
line-height: 1.5;
display: inline-block;
-webkit-appearance: none;
appearance: none;
cursor: pointer;
height: 40px;
line-height: 40px;
text-decoration: none;
border: solid 1px transparent;
text-align: center;
transition: all 250ms cubic-bezier(0.34, 1.61, 0.7, 1);
white-space: nowrap;
max-width: 100%;
vertical-align: middle;
box-shadow: 0 2px 2px -1px rgba(152, 162, 179, 0.3);
border-radius: 4px;
min-width: 112px;
background-color: #017D73;
border-color: #017D73;
color: #FFF;
box-shadow: 0 2px 2px -1px rgba(39, 87, 83, 0.3);
}
.is-tracking .track-config {
display: none;
}
#stopTrackingButton {
display: none;
}
.is-tracking #stopTrackingButton {
display: inline;
}
.is-tracking #startTrackingButton {
display: none;
}

View file

@ -11,20 +11,20 @@ import { ReactWrapper } from 'enzyme';
import { mountWithIntl } from '../enzyme_helpers';
import { WithMemoryRouter, WithRoute } from '../router_helpers';
import { WithStore } from '../redux_helpers';
import { TestBedOptions } from './types';
import { MemoryRouterConfig } from './types';
export const mountComponent = (
Component: ComponentType,
options: TestBedOptions,
memoryRouter: MemoryRouterConfig,
store: Store | null,
props: any
): ReactWrapper => {
const wrapWithRouter = options.memoryRouter.wrapComponent !== false;
const wrapWithRouter = memoryRouter.wrapComponent !== false;
let Comp;
if (wrapWithRouter) {
const { componentRoutePath, onRouter, initialEntries, initialIndex } = options.memoryRouter;
const { componentRoutePath, onRouter, initialEntries, initialIndex } = memoryRouter!;
// Wrap the componenet with a MemoryRouter and attach it to a react-router <Route />
Comp = WithMemoryRouter(initialEntries, initialIndex)(

View file

@ -8,32 +8,47 @@ import { ComponentType, ReactWrapper } from 'enzyme';
import { findTestSubject } from '../find_test_subject';
import { reactRouterMock } from '../router_helpers';
import { mountComponent, getJSXComponentWithProps } from './mount_component';
import { TestBedConfig, TestBedOptions, TestBed, SetupFunc } from './types';
import { TestBedConfig, TestBed, SetupFunc } from './types';
const defaultOptions: TestBedOptions = {
const defaultConfig: TestBedConfig = {
defaultProps: {},
memoryRouter: {
wrapComponent: true,
},
store: null,
};
/**
* Register a new test bed to test a React Component.
* Register a new Testbed to test a React Component.
*
* @param Component The component under test
* @param defaultProps The default props to pass to the component on each mount
* @param options An optional TestBedOptions object
* @param store An optional Redux store. It accepts a store or a function that returns a store
* @param config An optional configuration object for the Testbed
*
* @example
*
* const setup = registerTestBed(MyComponent, {}, undefined, myReduxStore);
* const { component } = setup(); // component is an Enzyme reactWrapper mounted and ready to be tested
```typescript
import { registerTestBed } from '../../../../test_utils';
import { RemoteClusterList } from '../../app/sections/remote_cluster_list';
import { remoteClustersStore } from '../../app/store';
const setup = registerTestBed(RemoteClusterList, { store: remoteClustersStore });
describe('<RemoteClusterList />, () > {
test('it should have a table', () => {
const { exists } = setup();
expect(exists('remoteClustersTable')).toBe(true);
});
});
```
*/
export const registerTestBed = <T extends string = string>(
Component: ComponentType<any>,
config?: TestBedConfig
): SetupFunc<T> => {
const { defaultProps = {}, options = defaultOptions, store = null } = config || {};
const {
defaultProps = defaultConfig.defaultProps,
memoryRouter = defaultConfig.memoryRouter!,
store = defaultConfig.store,
} = config || {};
/**
* In some cases, component have some logic that interacts with the react router
* _before_ the component is mounted.(Class constructor() I'm looking at you :)
@ -41,15 +56,15 @@ export const registerTestBed = <T extends string = string>(
* By adding the following lines, we make sure there is always a router available
* when instantiating the Component.
*/
if (options.memoryRouter.onRouter) {
options.memoryRouter.onRouter(reactRouterMock);
if (memoryRouter.onRouter) {
memoryRouter.onRouter(reactRouterMock);
}
const setup: SetupFunc<T> = props => {
// If a function was provided to create the store, execute it
const storeToMount = typeof store === 'function' ? store() : store;
// If a function is provided we execute it
const storeToMount = typeof store === 'function' ? store() : store!;
const component = mountComponent(Component, options, storeToMount, {
const component = mountComponent(Component, memoryRouter, storeToMount, {
...defaultProps,
...props,
});
@ -80,7 +95,7 @@ export const registerTestBed = <T extends string = string>(
find(testSubject).length === count;
const setProps: TestBed<T>['setProps'] = updatedProps => {
if (options.memoryRouter.wrapComponent !== false) {
if (memoryRouter.wrapComponent !== false) {
throw new Error(
'setProps() can only be called on a component **not** wrapped by a router route.'
);
@ -146,7 +161,7 @@ export const registerTestBed = <T extends string = string>(
*/
/**
* Parse an EUI table and return meta data information about its rows and colum content
* Parse an EUI table and return meta data information about its rows and colum content.
*
* @param tableTestSubject The data test subject of the EUI table
*/

View file

@ -27,21 +27,26 @@ export interface TestBed<T> {
/** The comonent under test */
component: ReactWrapper;
/**
* Look in the component if a data test subject exists, and return true or false
* Pass it a `data-test-subj` and it will return true if it exists or false if it does not exist.
*
* @param testSubject The data test subject to look for
* @param testSubject The data test subject to look for (can be a nested path. e.g. "detailPanel.mySection").
* @param count The number of times the subject needs to appear in order to return "true"
*/
exists: (testSubject: T, count?: number) => boolean;
/**
* Look for a data test subject in the component and return it.
* It is possible to target a nested test subject by separating it with a dot ('.');
* Pass it a `data-test-subj` and it will return an Enzyme reactWrapper of the node.
* You can target a nested test subject by separating it with a dot ('.');
*
* @param testSubject The data test subject to look for
*
* @example
* find('nameInput'); // if there is only 1 form, this is enough
* find('myForm.nameInput'); // if there are multiple forms, specify the test subject of the form
*
```ts
find('nameInput');
// or more specific,
// "nameInput" is a child of "myForm"
find('myForm.nameInput');
```
*/
find: (testSubject: T) => ReactWrapper;
/**
@ -59,7 +64,7 @@ export interface TestBed<T> {
* still need to wait until the next tick before the DOM updates.
* Setting isAsync to "true" takes care of that.
*
* @param input The form input. Can either be a data-test-subj or a reactWrapper
* @param input The form input. Can either be a data-test-subj or a reactWrapper (can be a nested path. e.g. "myForm.myInput").
* @param value The value to set
* @param isAsync If set to true will return a Promise that resolves on the next "tick"
*/
@ -71,26 +76,26 @@ export interface TestBed<T> {
/**
* Select or unselect a form checkbox.
*
* @param dataTestSubject The test subject of the checkbox
* @param dataTestSubject The test subject of the checkbox (can be a nested path. e.g. "myForm.mySelect").
* @param isChecked Defines if the checkobx is active or not
*/
selectCheckBox: (checkboxTestSubject: T, isChecked?: boolean) => void;
/**
* Toggle the EuiSwitch
*
* @param switchTestSubject The test subject of the EuiSwitch
* @param switchTestSubject The test subject of the EuiSwitch (can be a nested path. e.g. "myForm.mySwitch").
*/
toggleEuiSwitch: (switchTestSubject: T) => void;
/**
* The EUI ComboBox is a special input as it needs the ENTER key to be pressed
* in order to register the value set. This helpers automatically does that.
*
* @param comboBoxTestSubject The data test subject of the EuiComboBox
* @param comboBoxTestSubject The data test subject of the EuiComboBox (can be a nested path. e.g. "myForm.myComboBox").
* @param value The value to set
*/
setComboBoxValue: (comboBoxTestSubject: T, value: string) => void;
/**
* Get a list of the form error messages that are visible in the DOM of the component
* Get a list of the form error messages that are visible in the DOM.
*/
getErrorsMessages: () => string[];
};
@ -100,17 +105,23 @@ export interface TestBed<T> {
}
export interface TestBedConfig {
defaultProps: Record<string, any>;
options: TestBedOptions;
store: (() => Store) | Store | null;
/** The default props to pass to the mounted component. */
defaultProps?: Record<string, any>;
/** Configuration object for the react-router `MemoryRouter. */
memoryRouter?: MemoryRouterConfig;
/** An optional redux store. You can also provide a function that returns a store. */
store?: (() => Store) | Store | null;
}
export interface TestBedOptions {
memoryRouter: {
wrapComponent: boolean;
initialEntries?: string[];
initialIndex?: number;
componentRoutePath?: string;
onRouter?: (router: any) => void;
};
export interface MemoryRouterConfig {
/** Flag to add or not the `MemoryRouter`. If set to `false`, there won't be any router and the component won't be wrapped on a `<Route />`. */
wrapComponent?: boolean;
/** The React Router **initial entries** setting ([see documentation](https://github.com/ReactTraining/react-router/blob/master/packages/react-router/docs/api/MemoryRouter.md)) */
initialEntries?: string[];
/** The React Router **initial index** setting ([see documentation](https://github.com/ReactTraining/react-router/blob/master/packages/react-router/docs/api/MemoryRouter.md)) */
initialIndex?: number;
/** The route **path** for the mounted component (defaults to `"/"`) */
componentRoutePath?: string;
/** A callBack that will be called with the React Router instance once mounted */
onRouter?: (router: any) => void;
}