diff --git a/x-pack/plugins/cross_cluster_replication/__jest__/client_integration/helpers/auto_follow_pattern_add.helpers.js b/x-pack/plugins/cross_cluster_replication/__jest__/client_integration/helpers/auto_follow_pattern_add.helpers.js index 6a0b258111c4..40243566c1bf 100644 --- a/x-pack/plugins/cross_cluster_replication/__jest__/client_integration/helpers/auto_follow_pattern_add.helpers.js +++ b/x-pack/plugins/cross_cluster_replication/__jest__/client_integration/helpers/auto_follow_pattern_add.helpers.js @@ -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); diff --git a/x-pack/plugins/cross_cluster_replication/__jest__/client_integration/helpers/auto_follow_pattern_edit.helpers.js b/x-pack/plugins/cross_cluster_replication/__jest__/client_integration/helpers/auto_follow_pattern_edit.helpers.js index 19299c5745f9..7bc6d918c47e 100644 --- a/x-pack/plugins/cross_cluster_replication/__jest__/client_integration/helpers/auto_follow_pattern_edit.helpers.js +++ b/x-pack/plugins/cross_cluster_replication/__jest__/client_integration/helpers/auto_follow_pattern_edit.helpers.js @@ -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); diff --git a/x-pack/plugins/cross_cluster_replication/__jest__/client_integration/helpers/auto_follow_pattern_list.helpers.js b/x-pack/plugins/cross_cluster_replication/__jest__/client_integration/helpers/auto_follow_pattern_list.helpers.js index 9aa73045ee69..57f1d20619d7 100644 --- a/x-pack/plugins/cross_cluster_replication/__jest__/client_integration/helpers/auto_follow_pattern_list.helpers.js +++ b/x-pack/plugins/cross_cluster_replication/__jest__/client_integration/helpers/auto_follow_pattern_list.helpers.js @@ -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); diff --git a/x-pack/plugins/cross_cluster_replication/__jest__/client_integration/helpers/follower_index_add.helpers.js b/x-pack/plugins/cross_cluster_replication/__jest__/client_integration/helpers/follower_index_add.helpers.js index 2ce7f76f8675..9791fa0b732a 100644 --- a/x-pack/plugins/cross_cluster_replication/__jest__/client_integration/helpers/follower_index_add.helpers.js +++ b/x-pack/plugins/cross_cluster_replication/__jest__/client_integration/helpers/follower_index_add.helpers.js @@ -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); diff --git a/x-pack/plugins/cross_cluster_replication/__jest__/client_integration/helpers/follower_index_edit.helpers.js b/x-pack/plugins/cross_cluster_replication/__jest__/client_integration/helpers/follower_index_edit.helpers.js index f57148ea0b7c..3e2922d942d4 100644 --- a/x-pack/plugins/cross_cluster_replication/__jest__/client_integration/helpers/follower_index_edit.helpers.js +++ b/x-pack/plugins/cross_cluster_replication/__jest__/client_integration/helpers/follower_index_edit.helpers.js @@ -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); diff --git a/x-pack/plugins/cross_cluster_replication/__jest__/client_integration/helpers/follower_index_list.helpers.js b/x-pack/plugins/cross_cluster_replication/__jest__/client_integration/helpers/follower_index_list.helpers.js index d20fe80b41ba..85f372fc97e3 100644 --- a/x-pack/plugins/cross_cluster_replication/__jest__/client_integration/helpers/follower_index_list.helpers.js +++ b/x-pack/plugins/cross_cluster_replication/__jest__/client_integration/helpers/follower_index_list.helpers.js @@ -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); diff --git a/x-pack/plugins/cross_cluster_replication/__jest__/client_integration/helpers/home.helpers.js b/x-pack/plugins/cross_cluster_replication/__jest__/client_integration/helpers/home.helpers.js index 82e7dd06c69c..2372e1f31d50 100644 --- a/x-pack/plugins/cross_cluster_replication/__jest__/client_integration/helpers/home.helpers.js +++ b/x-pack/plugins/cross_cluster_replication/__jest__/client_integration/helpers/home.helpers.js @@ -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); diff --git a/x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/remote_clusters_add.helpers.js b/x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/remote_clusters_add.helpers.js index ec7d638b9ece..f4ce09071f42 100644 --- a/x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/remote_clusters_add.helpers.js +++ b/x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/remote_clusters_add.helpers.js @@ -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); diff --git a/x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/remote_clusters_edit.helpers.js b/x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/remote_clusters_edit.helpers.js index c91527164d15..ad4d07f99dab 100644 --- a/x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/remote_clusters_edit.helpers.js +++ b/x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/remote_clusters_edit.helpers.js @@ -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); diff --git a/x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/remote_clusters_list.helpers.js b/x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/remote_clusters_list.helpers.js index 48f6afb28c3d..8314fd14b85d 100644 --- a/x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/remote_clusters_list.helpers.js +++ b/x-pack/plugins/remote_clusters/__jest__/client_integration/helpers/remote_clusters_list.helpers.js @@ -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); diff --git a/x-pack/plugins/rollup/__jest__/client_integration/helpers/job_list.helpers.js b/x-pack/plugins/rollup/__jest__/client_integration/helpers/job_list.helpers.js index 7d761c252c68..9274319b3e2b 100644 --- a/x-pack/plugins/rollup/__jest__/client_integration/helpers/job_list.helpers.js +++ b/x-pack/plugins/rollup/__jest__/client_integration/helpers/job_list.helpers.js @@ -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); diff --git a/x-pack/test_utils/README.md b/x-pack/test_utils/README.md new file mode 100644 index 000000000000..e64c5c9d5978 --- /dev/null +++ b/x-pack/test_utils/README.md @@ -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 `` (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 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('', () => { + 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 +
+
+``` + +```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(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 `` 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 ``. (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('', () => { + test('it should be green', () => { + // Will mount + 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 ``. + +```js +... + +const { setup } = registerTestBed(RemoteClusterList); + +describe('', () => { + + 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 + + + + + + + + + +
Row 0, column 0Row 0, column 1
Row 1, column 0Row 1, column 1
+``` + +```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 `` 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. diff --git a/x-pack/test_utils/chrome_extension/test_subjects_finder/background.js b/x-pack/test_utils/chrome_extension/test_subjects_finder/background.js new file mode 100644 index 000000000000..c400b63e0d3b --- /dev/null +++ b/x-pack/test_utils/chrome_extension/test_subjects_finder/background.js @@ -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()] + }]); + }); +}); diff --git a/x-pack/test_utils/chrome_extension/test_subjects_finder/images/kibana128.png b/x-pack/test_utils/chrome_extension/test_subjects_finder/images/kibana128.png new file mode 100644 index 000000000000..d88ec6d8b4e4 Binary files /dev/null and b/x-pack/test_utils/chrome_extension/test_subjects_finder/images/kibana128.png differ diff --git a/x-pack/test_utils/chrome_extension/test_subjects_finder/images/kibana16.png b/x-pack/test_utils/chrome_extension/test_subjects_finder/images/kibana16.png new file mode 100644 index 000000000000..d88ec6d8b4e4 Binary files /dev/null and b/x-pack/test_utils/chrome_extension/test_subjects_finder/images/kibana16.png differ diff --git a/x-pack/test_utils/chrome_extension/test_subjects_finder/images/kibana32.png b/x-pack/test_utils/chrome_extension/test_subjects_finder/images/kibana32.png new file mode 100644 index 000000000000..d88ec6d8b4e4 Binary files /dev/null and b/x-pack/test_utils/chrome_extension/test_subjects_finder/images/kibana32.png differ diff --git a/x-pack/test_utils/chrome_extension/test_subjects_finder/images/kibana48.png b/x-pack/test_utils/chrome_extension/test_subjects_finder/images/kibana48.png new file mode 100644 index 000000000000..d88ec6d8b4e4 Binary files /dev/null and b/x-pack/test_utils/chrome_extension/test_subjects_finder/images/kibana48.png differ diff --git a/x-pack/test_utils/chrome_extension/test_subjects_finder/images/kibana64.png b/x-pack/test_utils/chrome_extension/test_subjects_finder/images/kibana64.png new file mode 100644 index 000000000000..d88ec6d8b4e4 Binary files /dev/null and b/x-pack/test_utils/chrome_extension/test_subjects_finder/images/kibana64.png differ diff --git a/x-pack/test_utils/chrome_extension/test_subjects_finder/manifest.json b/x-pack/test_utils/chrome_extension/test_subjects_finder/manifest.json new file mode 100644 index 000000000000..a954a281257b --- /dev/null +++ b/x-pack/test_utils/chrome_extension/test_subjects_finder/manifest.json @@ -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 +} diff --git a/x-pack/test_utils/chrome_extension/test_subjects_finder/popup.html b/x-pack/test_utils/chrome_extension/test_subjects_finder/popup.html new file mode 100644 index 000000000000..9c9acb0b5026 --- /dev/null +++ b/x-pack/test_utils/chrome_extension/test_subjects_finder/popup.html @@ -0,0 +1,40 @@ + + + + + + + + +

Test subjects finder

+
+
+
+ + +
+ The DOM node you want to start to traverse from. If not specified, the "body" will be used. +
+
+ +
+ + +
+ If you chose "typescript" you will get a Union Type ready to copy and paste in your test file. +
+
+
+ +
+ + +
+
+ + + + diff --git a/x-pack/test_utils/chrome_extension/test_subjects_finder/popup.js b/x-pack/test_utils/chrome_extension/test_subjects_finder/popup.js new file mode 100644 index 000000000000..20c1085b340d --- /dev/null +++ b/x-pack/test_utils/chrome_extension/test_subjects_finder/popup.js @@ -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(); + } +}); diff --git a/x-pack/test_utils/chrome_extension/test_subjects_finder/start_tracking_test_subjects.js b/x-pack/test_utils/chrome_extension/test_subjects_finder/start_tracking_test_subjects.js new file mode 100644 index 000000000000..97bdb3f3cc45 --- /dev/null +++ b/x-pack/test_utils/chrome_extension/test_subjects_finder/start_tracking_test_subjects.js @@ -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(); + }); +}()); diff --git a/x-pack/test_utils/chrome_extension/test_subjects_finder/stop_tracking_test_subjects.js b/x-pack/test_utils/chrome_extension/test_subjects_finder/stop_tracking_test_subjects.js new file mode 100644 index 000000000000..d46468999195 --- /dev/null +++ b/x-pack/test_utils/chrome_extension/test_subjects_finder/stop_tracking_test_subjects.js @@ -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; +} diff --git a/x-pack/test_utils/chrome_extension/test_subjects_finder/style.css b/x-pack/test_utils/chrome_extension/test_subjects_finder/style.css new file mode 100644 index 000000000000..7cf56cf14fcf --- /dev/null +++ b/x-pack/test_utils/chrome_extension/test_subjects_finder/style.css @@ -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; +} diff --git a/x-pack/test_utils/testbed/mount_component.tsx b/x-pack/test_utils/testbed/mount_component.tsx index afa5f0eb8876..437c0d1fad45 100644 --- a/x-pack/test_utils/testbed/mount_component.tsx +++ b/x-pack/test_utils/testbed/mount_component.tsx @@ -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 Comp = WithMemoryRouter(initialEntries, initialIndex)( diff --git a/x-pack/test_utils/testbed/testbed.ts b/x-pack/test_utils/testbed/testbed.ts index c7edfd3fa361..c87bb83bd46b 100644 --- a/x-pack/test_utils/testbed/testbed.ts +++ b/x-pack/test_utils/testbed/testbed.ts @@ -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(', () > { + test('it should have a table', () => { + const { exists } = setup(); + expect(exists('remoteClustersTable')).toBe(true); + }); + }); + ``` */ export const registerTestBed = ( Component: ComponentType, config?: TestBedConfig ): SetupFunc => { - 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 = ( * 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 = 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 = ( find(testSubject).length === count; const setProps: TestBed['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 = ( */ /** - * 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 */ diff --git a/x-pack/test_utils/testbed/types.ts b/x-pack/test_utils/testbed/types.ts index eedbfc3a6401..33a204c64332 100644 --- a/x-pack/test_utils/testbed/types.ts +++ b/x-pack/test_utils/testbed/types.ts @@ -27,21 +27,26 @@ export interface TestBed { /** 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 { * 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 { /** * 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 { } export interface TestBedConfig { - defaultProps: Record; - options: TestBedOptions; - store: (() => Store) | Store | null; + /** The default props to pass to the mounted component. */ + defaultProps?: Record; + /** 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 ``. */ + 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; }