[SR] Component integration tests (#36871) (#38922)

This commit is contained in:
Sébastien Loix 2019-06-14 06:45:16 +02:00 committed by GitHub
parent 717c7f2c48
commit 21b22c5b59
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
56 changed files with 2808 additions and 347 deletions

View file

@ -4,10 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import Chance from 'chance';
import { getRepository } from '../../../test/fixtures';
export const REPOSITORY_NAME = 'my-test-repository';
const chance = new Chance();
const CHARS_POOL = 'abcdefghijklmnopqrstuvwxyz';
export const getRandomString = (options = {}) =>
`${chance.string({ pool: CHARS_POOL, ...options })}-${Date.now()}`;
export const REPOSITORY_EDIT = getRepository({ name: REPOSITORY_NAME });

View file

@ -0,0 +1,392 @@
/*
* 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.
*/
import { act } from 'react-dom/test-utils';
import {
registerTestBed,
findTestSubject,
TestBed,
TestBedConfig,
nextTick,
} from '../../../../../test_utils';
import { SnapshotRestoreHome } from '../../../public/app/sections/home/home';
import { BASE_PATH } from '../../../public/app/constants';
import { WithProviders } from './providers';
const testBedConfig: TestBedConfig = {
memoryRouter: {
initialEntries: [`${BASE_PATH}/repositories`],
componentRoutePath: `${BASE_PATH}/:section(repositories|snapshots)/:repositoryName?/:snapshotId*`,
},
doMountAsync: true,
};
const initTestBed = registerTestBed(WithProviders(SnapshotRestoreHome), testBedConfig);
export interface HomeTestBed extends TestBed<HomeTestSubjects> {
actions: {
clickReloadButton: () => void;
selectRepositoryAt: (index: number) => void;
clickRepositoryAt: (index: number) => void;
clickSnapshotAt: (index: number) => void;
clickRepositoryActionAt: (index: number, action: 'delete' | 'edit') => void;
selectTab: (tab: 'snapshots' | 'repositories') => void;
selectSnapshotDetailTab: (tab: 'summary' | 'failedIndices') => void;
};
}
export const setup = async (): Promise<HomeTestBed> => {
const testBed = await initTestBed();
const REPOSITORY_TABLE = 'repositoryTable';
const SNAPSHOT_TABLE = 'snapshotTable';
const { find, table, router, component } = testBed;
/**
* User Actions
*/
const clickReloadButton = () => {
find('reloadButton').simulate('click');
};
const selectRepositoryAt = (index: number) => {
const { rows } = table.getMetaData(REPOSITORY_TABLE);
const row = rows[index];
const checkBox = row.reactWrapper.find('input').hostNodes();
checkBox.simulate('change', { target: { checked: true } });
};
const clickRepositoryAt = async (index: number) => {
const { rows } = table.getMetaData(REPOSITORY_TABLE);
const repositoryLink = findTestSubject(rows[index].reactWrapper, 'repositoryLink');
// @ts-ignore (remove when react 16.9.0 is released)
await act(async () => {
const { href } = repositoryLink.props();
router.navigateTo(href!);
await nextTick();
component.update();
});
};
const clickRepositoryActionAt = async (index: number, action: 'delete' | 'edit') => {
const { rows } = table.getMetaData('repositoryTable');
const currentRow = rows[index];
const lastColumn = currentRow.columns[currentRow.columns.length - 1].reactWrapper;
const button = findTestSubject(lastColumn, `${action}RepositoryButton`);
// @ts-ignore (remove when react 16.9.0 is released)
await act(async () => {
button.simulate('click');
component.update();
});
};
const clickSnapshotAt = async (index: number) => {
const { rows } = table.getMetaData(SNAPSHOT_TABLE);
const snapshotLink = findTestSubject(rows[index].reactWrapper, 'snapshotLink');
// @ts-ignore (remove when react 16.9.0 is released)
await act(async () => {
const { href } = snapshotLink.props();
router.navigateTo(href!);
await nextTick(100);
component.update();
});
};
const selectTab = (tab: 'repositories' | 'snapshots') => {
const tabs = ['snapshots', 'repositories'];
testBed
.find('tab')
.at(tabs.indexOf(tab))
.simulate('click');
};
const selectSnapshotDetailTab = (tab: 'summary' | 'failedIndices') => {
const tabs = ['summary', 'failedIndices'];
testBed
.find('snapshotDetail.tab')
.at(tabs.indexOf(tab))
.simulate('click');
};
return {
...testBed,
actions: {
clickReloadButton,
selectRepositoryAt,
clickRepositoryAt,
clickRepositoryActionAt,
clickSnapshotAt,
selectTab,
selectSnapshotDetailTab,
},
};
};
type HomeTestSubjects = TestSubjects | ThreeLevelDepth | NonVisibleTestSubjects;
type NonVisibleTestSubjects =
| 'snapshotDetail.sectionLoading'
| 'sectionLoading'
| 'emptyPrompt'
| 'emptyPrompt.documentationLink'
| 'emptyPrompt.title'
| 'emptyPrompt.registerRepositoryButton'
| 'repositoryDetail.sectionLoading'
| 'snapshotDetail.indexFailure';
type ThreeLevelDepth =
| 'snapshotDetail.uuid.value'
| 'snapshotDetail.state.value'
| 'snapshotDetail.version.value'
| 'snapshotDetail.includeGlobalState.value'
| 'snapshotDetail.indices.title'
| 'snapshotDetail.startTime.value'
| 'snapshotDetail.endTime.value'
| 'snapshotDetail.indexFailure.index'
| 'snapshotDetail.indices.value';
export type TestSubjects =
| 'appTitle'
| 'cell'
| 'cell.repositoryLink'
| 'cell.snapshotLink'
| 'checkboxSelectAll'
| 'checkboxSelectRow-my-repo'
| 'closeButton'
| 'content'
| 'content.documentationLink'
| 'content.duration'
| 'content.endTime'
| 'content.includeGlobalState'
| 'content.indices'
| 'content.repositoryType'
| 'content.snapshotCount'
| 'content.startTime'
| 'content.state'
| 'content.title'
| 'content.uuid'
| 'content.value'
| 'content.verifyRepositoryButton'
| 'content.version'
| 'deleteRepositoryButton'
| 'detailTitle'
| 'documentationLink'
| 'duration'
| 'duration.title'
| 'duration.value'
| 'editRepositoryButton'
| 'endTime'
| 'endTime.title'
| 'endTime.value'
| 'euiFlyoutCloseButton'
| 'includeGlobalState'
| 'includeGlobalState.title'
| 'includeGlobalState.value'
| 'indices'
| 'indices.title'
| 'indices.value'
| 'registerRepositoryButton'
| 'reloadButton'
| 'repositoryDetail'
| 'repositoryDetail.content'
| 'repositoryDetail.documentationLink'
| 'repositoryDetail.euiFlyoutCloseButton'
| 'repositoryDetail.repositoryType'
| 'repositoryDetail.snapshotCount'
| 'repositoryDetail.srRepositoryDetailsDeleteActionButton'
| 'repositoryDetail.srRepositoryDetailsFlyoutCloseButton'
| 'repositoryDetail.title'
| 'repositoryDetail.verifyRepositoryButton'
| 'repositoryLink'
| 'repositoryList'
| 'repositoryList.cell'
| 'repositoryList.checkboxSelectAll'
| 'repositoryList.checkboxSelectRow-my-repo'
| 'repositoryList.content'
| 'repositoryList.deleteRepositoryButton'
| 'repositoryList.documentationLink'
| 'repositoryList.editRepositoryButton'
| 'repositoryList.euiFlyoutCloseButton'
| 'repositoryList.registerRepositoryButton'
| 'repositoryList.reloadButton'
| 'repositoryList.repositoryDetail'
| 'repositoryList.repositoryLink'
| 'repositoryList.repositoryTable'
| 'repositoryList.repositoryType'
| 'repositoryList.row'
| 'repositoryList.snapshotCount'
| 'repositoryList.srRepositoryDetailsDeleteActionButton'
| 'repositoryList.srRepositoryDetailsFlyoutCloseButton'
| 'repositoryList.tableHeaderCell_name_0'
| 'repositoryList.tableHeaderCell_type_1'
| 'repositoryList.tableHeaderSortButton'
| 'repositoryList.title'
| 'repositoryList.verifyRepositoryButton'
| 'repositoryTable'
| 'repositoryTable.cell'
| 'repositoryTable.checkboxSelectAll'
| 'repositoryTable.checkboxSelectRow-my-repo'
| 'repositoryTable.deleteRepositoryButton'
| 'repositoryTable.editRepositoryButton'
| 'repositoryTable.repositoryLink'
| 'repositoryTable.row'
| 'repositoryTable.tableHeaderCell_name_0'
| 'repositoryTable.tableHeaderCell_type_1'
| 'repositoryTable.tableHeaderSortButton'
| 'repositoryType'
| 'row'
| 'row.cell'
| 'row.checkboxSelectRow-my-repo'
| 'row.deleteRepositoryButton'
| 'row.editRepositoryButton'
| 'row.repositoryLink'
| 'row.snapshotLink'
| 'snapshotCount'
| 'snapshotDetail'
| 'snapshotDetail.closeButton'
| 'snapshotDetail.content'
| 'snapshotDetail.detailTitle'
| 'snapshotDetail.duration'
| 'snapshotDetail.endTime'
| 'snapshotDetail.euiFlyoutCloseButton'
| 'snapshotDetail.includeGlobalState'
| 'snapshotDetail.indices'
| 'snapshotDetail.repositoryLink'
| 'snapshotDetail.startTime'
| 'snapshotDetail.state'
| 'snapshotDetail.tab'
| 'snapshotDetail.title'
| 'snapshotDetail.uuid'
| 'snapshotDetail.value'
| 'snapshotDetail.version'
| 'snapshotLink'
| 'snapshotList'
| 'snapshotList.cell'
| 'snapshotList.closeButton'
| 'snapshotList.content'
| 'snapshotList.detailTitle'
| 'snapshotList.duration'
| 'snapshotList.endTime'
| 'snapshotList.euiFlyoutCloseButton'
| 'snapshotList.includeGlobalState'
| 'snapshotList.indices'
| 'snapshotList.reloadButton'
| 'snapshotList.repositoryLink'
| 'snapshotList.row'
| 'snapshotList.snapshotDetail'
| 'snapshotList.snapshotLink'
| 'snapshotList.snapshotTable'
| 'snapshotList.startTime'
| 'snapshotList.state'
| 'snapshotList.tab'
| 'snapshotList.tableHeaderCell_durationInMillis_3'
| 'snapshotList.tableHeaderCell_indices_4'
| 'snapshotList.tableHeaderCell_repository_1'
| 'snapshotList.tableHeaderCell_snapshot_0'
| 'snapshotList.tableHeaderCell_startTimeInMillis_2'
| 'snapshotList.tableHeaderSortButton'
| 'snapshotList.title'
| 'snapshotList.uuid'
| 'snapshotList.value'
| 'snapshotList.version'
| 'snapshotRestoreApp'
| 'snapshotRestoreApp.appTitle'
| 'snapshotRestoreApp.cell'
| 'snapshotRestoreApp.checkboxSelectAll'
| 'snapshotRestoreApp.checkboxSelectRow-my-repo'
| 'snapshotRestoreApp.closeButton'
| 'snapshotRestoreApp.content'
| 'snapshotRestoreApp.deleteRepositoryButton'
| 'snapshotRestoreApp.detailTitle'
| 'snapshotRestoreApp.documentationLink'
| 'snapshotRestoreApp.duration'
| 'snapshotRestoreApp.editRepositoryButton'
| 'snapshotRestoreApp.endTime'
| 'snapshotRestoreApp.euiFlyoutCloseButton'
| 'snapshotRestoreApp.includeGlobalState'
| 'snapshotRestoreApp.indices'
| 'snapshotRestoreApp.registerRepositoryButton'
| 'snapshotRestoreApp.reloadButton'
| 'snapshotRestoreApp.repositoryDetail'
| 'snapshotRestoreApp.repositoryLink'
| 'snapshotRestoreApp.repositoryList'
| 'snapshotRestoreApp.repositoryTable'
| 'snapshotRestoreApp.repositoryType'
| 'snapshotRestoreApp.row'
| 'snapshotRestoreApp.snapshotCount'
| 'snapshotRestoreApp.snapshotDetail'
| 'snapshotRestoreApp.snapshotLink'
| 'snapshotRestoreApp.snapshotList'
| 'snapshotRestoreApp.snapshotTable'
| 'snapshotRestoreApp.srRepositoryDetailsDeleteActionButton'
| 'snapshotRestoreApp.srRepositoryDetailsFlyoutCloseButton'
| 'snapshotRestoreApp.startTime'
| 'snapshotRestoreApp.state'
| 'snapshotRestoreApp.tab'
| 'snapshotRestoreApp.tableHeaderCell_durationInMillis_3'
| 'snapshotRestoreApp.tableHeaderCell_indices_4'
| 'snapshotRestoreApp.tableHeaderCell_name_0'
| 'snapshotRestoreApp.tableHeaderCell_repository_1'
| 'snapshotRestoreApp.tableHeaderCell_snapshot_0'
| 'snapshotRestoreApp.tableHeaderCell_startTimeInMillis_2'
| 'snapshotRestoreApp.tableHeaderCell_type_1'
| 'snapshotRestoreApp.tableHeaderSortButton'
| 'snapshotRestoreApp.title'
| 'snapshotRestoreApp.uuid'
| 'snapshotRestoreApp.value'
| 'snapshotRestoreApp.verifyRepositoryButton'
| 'snapshotRestoreApp.version'
| 'snapshotTable'
| 'snapshotTable.cell'
| 'snapshotTable.repositoryLink'
| 'snapshotTable.row'
| 'snapshotTable.snapshotLink'
| 'snapshotTable.tableHeaderCell_durationInMillis_3'
| 'snapshotTable.tableHeaderCell_indices_4'
| 'snapshotTable.tableHeaderCell_repository_1'
| 'snapshotTable.tableHeaderCell_snapshot_0'
| 'snapshotTable.tableHeaderCell_startTimeInMillis_2'
| 'snapshotTable.tableHeaderSortButton'
| 'srRepositoryDetailsDeleteActionButton'
| 'srRepositoryDetailsFlyoutCloseButton'
| 'startTime'
| 'startTime.title'
| 'startTime.value'
| 'state'
| 'state.title'
| 'state.value'
| 'tab'
| 'tableHeaderCell_durationInMillis_3'
| 'tableHeaderCell_durationInMillis_3.tableHeaderSortButton'
| 'tableHeaderCell_indices_4'
| 'tableHeaderCell_indices_4.tableHeaderSortButton'
| 'tableHeaderCell_name_0'
| 'tableHeaderCell_name_0.tableHeaderSortButton'
| 'tableHeaderCell_repository_1'
| 'tableHeaderCell_repository_1.tableHeaderSortButton'
| 'tableHeaderCell_shards.failed_6'
| 'tableHeaderCell_shards.total_5'
| 'tableHeaderCell_snapshot_0'
| 'tableHeaderCell_snapshot_0.tableHeaderSortButton'
| 'tableHeaderCell_startTimeInMillis_2'
| 'tableHeaderCell_startTimeInMillis_2.tableHeaderSortButton'
| 'tableHeaderCell_type_1'
| 'tableHeaderCell_type_1.tableHeaderSortButton'
| 'tableHeaderSortButton'
| 'title'
| 'uuid'
| 'uuid.title'
| 'uuid.value'
| 'value'
| 'verifyRepositoryButton'
| 'version'
| 'version.title'
| 'version.value';

View file

@ -0,0 +1,100 @@
/*
* 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.
*/
import sinon, { SinonFakeServer } from 'sinon';
import { API_BASE_PATH } from '../../../common/constants';
type HttpResponse = Record<string, any> | any[];
const mockResponse = (defaultResponse: HttpResponse, response: HttpResponse) => [
200,
{ 'Content-Type': 'application/json' },
JSON.stringify({ ...defaultResponse, ...response }),
];
// Register helpers to mock HTTP Requests
const registerHttpRequestMockHelpers = (server: SinonFakeServer) => {
const setLoadRepositoriesResponse = (response: HttpResponse = {}) => {
const defaultResponse = { repositories: [] };
server.respondWith(
'GET',
`${API_BASE_PATH}repositories`,
mockResponse(defaultResponse, response)
);
};
const setLoadRepositoryTypesResponse = (response: HttpResponse = []) => {
server.respondWith('GET', `${API_BASE_PATH}repository_types`, JSON.stringify(response));
};
const setGetRepositoryResponse = (response?: HttpResponse) => {
const defaultResponse = {};
server.respondWith(
'GET',
/api\/snapshot_restore\/repositories\/.+/,
response
? mockResponse(defaultResponse, response)
: [200, { 'Content-Type': 'application/json' }, '']
);
};
const setSaveRepositoryResponse = (response?: HttpResponse, error?: any) => {
const status = error ? error.status || 400 : 200;
const body = error ? JSON.stringify(error.body) : JSON.stringify(response);
server.respondWith('PUT', `${API_BASE_PATH}repositories`, [
status,
{ 'Content-Type': 'application/json' },
body,
]);
};
const setLoadSnapshotsResponse = (response: HttpResponse = {}) => {
const defaultResponse = { errors: {}, snapshots: [], repositories: [] };
server.respondWith('GET', `${API_BASE_PATH}snapshots`, mockResponse(defaultResponse, response));
};
const setGetSnapshotResponse = (response?: HttpResponse) => {
const defaultResponse = {};
server.respondWith(
'GET',
/\/api\/snapshot_restore\/snapshots\/.+/,
response
? mockResponse(defaultResponse, response)
: [200, { 'Content-Type': 'application/json' }, '']
);
};
return {
setLoadRepositoriesResponse,
setLoadRepositoryTypesResponse,
setGetRepositoryResponse,
setSaveRepositoryResponse,
setLoadSnapshotsResponse,
setGetSnapshotResponse,
};
};
export const init = () => {
const server = sinon.fakeServer.create();
server.respondImmediately = true;
// Define default response for unhandled requests.
// We make requests to APIs which don't impact the component under test, e.g. UI metric telemetry,
// and we can mock them all with a 200 instead of mocking each one individually.
server.respondWith([200, {}, 'DefaultResponse']);
const httpRequestsMockHelpers = registerHttpRequestMockHelpers(server);
return {
server,
httpRequestsMockHelpers,
};
};

View file

@ -0,0 +1,19 @@
/*
* 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.
*/
import { setup as homeSetup } from './home.helpers';
import { setup as repositoryAddSetup } from './repository_add.helpers';
import { setup as repositoryEditSetup } from './repository_edit.helpers';
export { nextTick, getRandomString, findTestSubject, TestBed } from '../../../../../test_utils';
export { setupEnvironment } from './setup_environment';
export const pageHelpers = {
home: { setup: homeSetup },
repositoryAdd: { setup: repositoryAddSetup },
repositoryEdit: { setup: repositoryEditSetup },
};

View file

@ -0,0 +1,29 @@
/*
* 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.
*/
import React, { ComponentClass, FunctionComponent } from 'react';
import { createShim } from '../../../public/shim';
import { setAppDependencies } from '../../../public/app/index';
const { core, plugins } = createShim();
const appDependencies = {
core,
plugins,
};
type ComponentType = ComponentClass<any> | FunctionComponent<any>;
export const WithProviders = (Comp: ComponentType) => {
const AppDependenciesProvider = setAppDependencies(appDependencies);
return (props: any) => {
return (
<AppDependenciesProvider value={appDependencies}>
<Comp {...props} />
</AppDependenciesProvider>
);
};
};

View file

@ -0,0 +1,129 @@
/*
* 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.
*/
import { registerTestBed, TestBed } from '../../../../../test_utils';
import { RepositoryType } from '../../../common/types';
import { RepositoryAdd } from '../../../public/app/sections/repository_add';
import { WithProviders } from './providers';
const initTestBed = registerTestBed<RepositoryAddTestSubjects>(WithProviders(RepositoryAdd), {
doMountAsync: true,
});
export interface RepositoryAddTestBed extends TestBed<RepositoryAddTestSubjects> {
actions: {
clickNextButton: () => void;
clickBackButton: () => void;
clickSubmitButton: () => void;
selectRepositoryType: (type: RepositoryType) => void;
};
}
export const setup = async (): Promise<RepositoryAddTestBed> => {
const testBed = await initTestBed();
// User actions
const clickNextButton = () => {
testBed.find('nextButton').simulate('click');
};
const clickBackButton = () => {
testBed.find('backButton').simulate('click');
};
const clickSubmitButton = () => {
testBed.find('submitButton').simulate('click');
};
const selectRepositoryType = (type: RepositoryType) => {
const button = testBed.find(`${type}RepositoryType` as 'fsRepositoryType').find('button');
if (!button.length) {
throw new Error(`Repository type "${type}" button not found.`);
}
button.simulate('click');
};
return {
...testBed,
actions: {
clickNextButton,
clickBackButton,
clickSubmitButton,
selectRepositoryType,
},
};
};
export type RepositoryAddTestSubjects = TestSubjects | NonVisibleTestSubjects;
type NonVisibleTestSubjects =
| 'noRepositoryTypesError'
| 'sectionLoading'
| 'saveRepositoryApiError';
type TestSubjects =
| 'backButton'
| 'chunkSizeInput'
| 'compressToggle'
| 'fsRepositoryType'
| 'locationInput'
| 'maxRestoreBytesInput'
| 'maxSnapshotBytesInput'
| 'nameInput'
| 'nextButton'
| 'pageTitle'
| 'readOnlyToggle'
| 'repositoryForm'
| 'repositoryForm.backButton'
| 'repositoryForm.chunkSizeInput'
| 'repositoryForm.compressToggle'
| 'repositoryForm.fsRepositoryType'
| 'repositoryForm.locationInput'
| 'repositoryForm.maxRestoreBytesInput'
| 'repositoryForm.maxSnapshotBytesInput'
| 'repositoryForm.nameInput'
| 'repositoryForm.nextButton'
| 'repositoryForm.readOnlyToggle'
| 'repositoryForm.repositoryFormError'
| 'repositoryForm.sourceOnlyToggle'
| 'repositoryForm.stepTwo'
| 'repositoryForm.submitButton'
| 'repositoryForm.title'
| 'repositoryForm.urlRepositoryType'
| 'repositoryFormError'
| 'snapshotRestoreApp'
| 'snapshotRestoreApp.backButton'
| 'snapshotRestoreApp.chunkSizeInput'
| 'snapshotRestoreApp.compressToggle'
| 'snapshotRestoreApp.fsRepositoryType'
| 'snapshotRestoreApp.locationInput'
| 'snapshotRestoreApp.maxRestoreBytesInput'
| 'snapshotRestoreApp.maxSnapshotBytesInput'
| 'snapshotRestoreApp.nameInput'
| 'snapshotRestoreApp.nextButton'
| 'snapshotRestoreApp.pageTitle'
| 'snapshotRestoreApp.readOnlyToggle'
| 'snapshotRestoreApp.repositoryForm'
| 'snapshotRestoreApp.repositoryFormError'
| 'snapshotRestoreApp.sourceOnlyToggle'
| 'snapshotRestoreApp.stepTwo'
| 'snapshotRestoreApp.submitButton'
| 'snapshotRestoreApp.title'
| 'snapshotRestoreApp.urlRepositoryType'
| 'sourceOnlyToggle'
| 'stepTwo'
| 'stepTwo.backButton'
| 'stepTwo.chunkSizeInput'
| 'stepTwo.compressToggle'
| 'stepTwo.locationInput'
| 'stepTwo.maxRestoreBytesInput'
| 'stepTwo.maxSnapshotBytesInput'
| 'stepTwo.readOnlyToggle'
| 'stepTwo.submitButton'
| 'stepTwo.title'
| 'submitButton'
| 'title'
| 'urlRepositoryType';

View file

@ -0,0 +1,85 @@
/*
* 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.
*/
import { registerTestBed, TestBedConfig } from '../../../../../test_utils';
import { RepositoryEdit } from '../../../public/app/sections/repository_edit';
import { WithProviders } from './providers';
import { REPOSITORY_NAME } from './constant';
const testBedConfig: TestBedConfig = {
memoryRouter: {
initialEntries: [`/${REPOSITORY_NAME}`],
componentRoutePath: '/:name',
},
doMountAsync: true,
};
export const setup = registerTestBed<RepositoryEditTestSubjects>(
WithProviders(RepositoryEdit),
testBedConfig
);
export type RepositoryEditTestSubjects = TestSubjects | ThreeLevelDepth | NonVisibleTestSubjects;
type NonVisibleTestSubjects =
| 'uriInput'
| 'schemeSelect'
| 'clientInput'
| 'containerInput'
| 'basePathInput'
| 'maxSnapshotBytesInput'
| 'locationModeSelect'
| 'bucketInput'
| 'urlInput'
| 'pathInput'
| 'loadDefaultsToggle'
| 'securityPrincipalInput'
| 'serverSideEncryptionToggle'
| 'bufferSizeInput'
| 'cannedAclSelect'
| 'storageClassSelect';
type ThreeLevelDepth = 'repositoryForm.stepTwo.title';
type TestSubjects =
| 'chunkSizeInput'
| 'compressToggle'
| 'locationInput'
| 'maxRestoreBytesInput'
| 'maxSnapshotBytesInput'
| 'readOnlyToggle'
| 'repositoryForm'
| 'repositoryForm.chunkSizeInput'
| 'repositoryForm.compressToggle'
| 'repositoryForm.locationInput'
| 'repositoryForm.maxRestoreBytesInput'
| 'repositoryForm.maxSnapshotBytesInput'
| 'repositoryForm.readOnlyToggle'
| 'repositoryForm.stepTwo'
| 'repositoryForm.submitButton'
| 'repositoryForm.title'
| 'snapshotRestoreApp'
| 'snapshotRestoreApp.chunkSizeInput'
| 'snapshotRestoreApp.compressToggle'
| 'snapshotRestoreApp.locationInput'
| 'snapshotRestoreApp.maxRestoreBytesInput'
| 'snapshotRestoreApp.maxSnapshotBytesInput'
| 'snapshotRestoreApp.readOnlyToggle'
| 'snapshotRestoreApp.repositoryForm'
| 'snapshotRestoreApp.stepTwo'
| 'snapshotRestoreApp.submitButton'
| 'snapshotRestoreApp.title'
| 'stepTwo'
| 'stepTwo.chunkSizeInput'
| 'stepTwo.compressToggle'
| 'stepTwo.locationInput'
| 'stepTwo.maxRestoreBytesInput'
| 'stepTwo.maxSnapshotBytesInput'
| 'stepTwo.readOnlyToggle'
| 'stepTwo.submitButton'
| 'stepTwo.title'
| 'submitButton'
| 'title';

View file

@ -0,0 +1,31 @@
/*
* 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.
*/
import axios from 'axios';
import axiosXhrAdapter from 'axios/lib/adapters/xhr';
import { i18n } from '@kbn/i18n';
import { httpService } from '../../../public/app/services/http';
import { breadcrumbService } from '../../../public/app/services/navigation';
import { textService } from '../../../public/app/services/text';
import { chrome } from '../../../public/test/mocks';
import { init as initHttpRequests } from './http_requests';
export const setupEnvironment = () => {
httpService.init(axios.create({ adapter: axiosXhrAdapter }), {
addBasePath: (path: string) => path,
});
breadcrumbService.init(chrome, {});
textService.init(i18n);
const { server, httpRequestsMockHelpers } = initHttpRequests();
return {
server,
httpRequestsMockHelpers,
};
};

View file

@ -0,0 +1,736 @@
/*
* 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.
*/
import { act } from 'react-dom/test-utils';
import * as fixtures from '../../test/fixtures';
import { SNAPSHOT_STATE } from '../../public/app/constants';
import { API_BASE_PATH } from '../../common/constants';
import { formatDate } from '../../public/app/services/text';
import {
setupEnvironment,
pageHelpers,
nextTick,
getRandomString,
findTestSubject,
} from './helpers';
import { HomeTestBed } from './helpers/home.helpers';
import { REPOSITORY_NAME } from './helpers/constant';
const { setup } = pageHelpers.home;
jest.mock('ui/i18n', () => {
const I18nContext = ({ children }: any) => children;
return { I18nContext };
});
const removeWhiteSpaceOnArrayValues = (array: any[]) =>
array.map(value => {
if (!value.trim) {
return value;
}
return value.trim();
});
// We need to skip the tests until react 16.9.0 is released
// which supports asynchronous code inside act()
describe.skip('<SnapshotRestoreHome />', () => {
const { server, httpRequestsMockHelpers } = setupEnvironment();
let testBed: HomeTestBed;
afterAll(() => {
server.restore();
});
describe('on component mount', () => {
beforeEach(async () => {
testBed = await setup();
});
test('should set the correct app title', () => {
const { exists, find } = testBed;
expect(exists('appTitle')).toBe(true);
expect(find('appTitle').text()).toEqual('Snapshot Repositories');
});
test('should display a loading while fetching the repositories', () => {
const { exists, find } = testBed;
expect(exists('sectionLoading')).toBe(true);
expect(find('sectionLoading').text()).toEqual('Loading repositories…');
});
test('should have a link to the documentation', () => {
const { exists, find } = testBed;
expect(exists('documentationLink')).toBe(true);
expect(find('documentationLink').text()).toBe('Snapshot docs');
});
describe('tabs', () => {
beforeEach(async () => {
testBed = await setup();
// @ts-ignore (remove when react 16.9.0 is released)
await act(async () => {
await nextTick();
testBed.component.update();
});
});
test('should have 2 tabs', () => {
const { find } = testBed;
expect(find('tab').length).toBe(2);
expect(find('tab').map(t => t.text())).toEqual(['Snapshots', 'Repositories']);
});
test('should navigate to snapshot list tab', () => {
const { exists, actions } = testBed;
expect(exists('repositoryList')).toBe(true);
expect(exists('snapshotList')).toBe(false);
actions.selectTab('snapshots');
expect(exists('repositoryList')).toBe(false);
expect(exists('snapshotList')).toBe(true);
});
});
});
describe('repositories', () => {
describe('when there are no repositories', () => {
beforeEach(() => {
httpRequestsMockHelpers.setLoadRepositoriesResponse({ repositories: [] });
});
test('should display an empty prompt', async () => {
const { component, exists } = await setup();
// @ts-ignore (remove when react 16.9.0 is released)
await act(async () => {
await nextTick();
component.update();
});
expect(exists('sectionLoading')).toBe(false);
expect(exists('emptyPrompt')).toBe(true);
expect(exists('emptyPrompt.registerRepositoryButton')).toBe(true);
});
});
describe('when there are repositories', () => {
const repo1 = fixtures.getRepository({ name: `a${getRandomString()}`, type: 'fs' });
const repo2 = fixtures.getRepository({ name: `b${getRandomString()}`, type: 'url' });
const repo3 = fixtures.getRepository({ name: `c${getRandomString()}`, type: 's3' });
const repo4 = fixtures.getRepository({ name: `d${getRandomString()}`, type: 'hdfs' });
const repo5 = fixtures.getRepository({ name: `e${getRandomString()}`, type: 'azure' });
const repo6 = fixtures.getRepository({ name: `f${getRandomString()}`, type: 'gcs' });
const repo7 = fixtures.getRepository({ name: `g${getRandomString()}`, type: 'source' });
const repo8 = fixtures.getRepository({
name: `h${getRandomString()}`,
type: 'source',
settings: { delegateType: 'gcs' },
});
const repositories = [repo1, repo2, repo3, repo4, repo5, repo6, repo7, repo8];
beforeEach(async () => {
httpRequestsMockHelpers.setLoadRepositoriesResponse({ repositories });
testBed = await setup();
// @ts-ignore (remove when react 16.9.0 is released)
await act(async () => {
await nextTick();
testBed.component.update();
});
});
test('should list them in the table', async () => {
const { table } = testBed;
const mapTypeToText: Record<string, string> = {
fs: 'Shared file system',
url: 'Read-only URL',
s3: 'AWS S3',
hdfs: 'Hadoop HDFS',
azure: 'Azure',
gcs: 'Google Cloud Storage',
source: 'Source-only',
};
const { tableCellsValues } = table.getMetaData('repositoryTable');
tableCellsValues.forEach((row, i) => {
const repository = repositories[i];
if (repository === repo8) {
// The "repo8" is source with a delegate type
expect(removeWhiteSpaceOnArrayValues(row)).toEqual([
'',
repository.name,
`${mapTypeToText[repository.settings.delegateType]} (Source-only)`,
'',
]);
} else {
expect(removeWhiteSpaceOnArrayValues(row)).toEqual([
'',
repository.name,
mapTypeToText[repository.type],
'',
]);
}
});
});
test('should have a button to reload the repositories', async () => {
const { component, exists, actions } = testBed;
const totalRequests = server.requests.length;
expect(exists('reloadButton')).toBe(true);
// @ts-ignore (remove when react 16.9.0 is released)
await act(async () => {
actions.clickReloadButton();
await nextTick();
component.update();
});
expect(server.requests.length).toBe(totalRequests + 1);
expect(server.requests[server.requests.length - 1].url).toBe(
`${API_BASE_PATH}repositories`
);
});
test('should have a button to register a new repository', () => {
const { exists } = testBed;
expect(exists('registerRepositoryButton')).toBe(true);
});
test('should have action buttons on each row to edit and delete a repository', () => {
const { table } = testBed;
const { rows } = table.getMetaData('repositoryTable');
const lastColumn = rows[0].columns[rows[0].columns.length - 1].reactWrapper;
expect(findTestSubject(lastColumn, 'editRepositoryButton').length).toBe(1);
expect(findTestSubject(lastColumn, 'deleteRepositoryButton').length).toBe(1);
});
describe('delete repository', () => {
test('should show a confirmation when clicking the delete repository button', async () => {
const { actions } = testBed;
await actions.clickRepositoryActionAt(0, 'delete');
// We need to read the document "body" as the modal is added there and not inside
// the component DOM tree.
expect(
document.body.querySelector('[data-test-subj="deleteRepositoryConfirmation"]')
).not.toBe(null);
expect(
document.body.querySelector('[data-test-subj="deleteRepositoryConfirmation"]')!
.textContent
).toContain(`Remove repository '${repo1.name}'?`);
});
test('should send the correct HTTP request to delete repository', async () => {
const { component, actions } = testBed;
await actions.clickRepositoryActionAt(0, 'delete');
const modal = document.body.querySelector(
'[data-test-subj="deleteRepositoryConfirmation"]'
);
const confirmButton: HTMLButtonElement | null = modal!.querySelector(
'[data-test-subj="confirmModalConfirmButton"]'
);
// @ts-ignore (remove when react 16.9.0 is released)
await act(async () => {
confirmButton!.click();
await nextTick();
component.update();
});
const latestRequest = server.requests[server.requests.length - 1];
expect(latestRequest.method).toBe('DELETE');
expect(latestRequest.url).toBe(`${API_BASE_PATH}repositories/${repo1.name}`);
});
});
describe('detail panel', () => {
test('should show the detail when clicking on a repository', async () => {
const { exists, actions } = testBed;
expect(exists('repositoryDetail')).toBe(false);
await actions.clickRepositoryAt(0);
expect(exists('repositoryDetail')).toBe(true);
});
test('should set the correct title', async () => {
const { find, actions } = testBed;
await actions.clickRepositoryAt(0);
expect(find('repositoryDetail.title').text()).toEqual(repo1.name);
});
test('should show a loading state while fetching the repository', async () => {
const { find, exists, actions } = testBed;
// By providing undefined, the "loading section" will be displayed
httpRequestsMockHelpers.setGetRepositoryResponse(undefined);
await actions.clickRepositoryAt(0);
expect(exists('repositoryDetail.sectionLoading')).toBe(true);
expect(find('repositoryDetail.sectionLoading').text()).toEqual('Loading repository…');
});
describe('when the repository has been fetched', () => {
beforeEach(async () => {
httpRequestsMockHelpers.setGetRepositoryResponse({
repository: {
name: 'my-repo',
type: 'fs',
settings: { location: '/tmp/es-backups' },
},
snapshots: { count: 0 },
});
await testBed.actions.clickRepositoryAt(0);
});
test('should have a link to the documentation', async () => {
const { exists } = testBed;
expect(exists('repositoryDetail.documentationLink')).toBe(true);
});
test('should set the correct repository settings', () => {
const { find } = testBed;
expect(find('repositoryDetail.repositoryType').text()).toEqual('Shared file system');
expect(find('repositoryDetail.snapshotCount').text()).toEqual(
'Repository has no snapshots'
);
});
test('should have a button to verify the status of the repository', async () => {
const { exists, find, component } = testBed;
expect(exists('repositoryDetail.verifyRepositoryButton')).toBe(true);
// @ts-ignore (remove when react 16.9.0 is released)
await act(async () => {
find('repositoryDetail.verifyRepositoryButton').simulate('click');
await nextTick();
component.update();
});
const latestRequest = server.requests[server.requests.length - 1];
expect(latestRequest.method).toBe('GET');
expect(latestRequest.url).toBe(`${API_BASE_PATH}repositories/${repo1.name}/verify`);
});
});
describe('when the repository has been fetched (and has snapshots)', () => {
beforeEach(async () => {
httpRequestsMockHelpers.setGetRepositoryResponse({
repository: {
name: 'my-repo',
type: 'fs',
settings: { location: '/tmp/es-backups' },
},
snapshots: { count: 2 },
});
await testBed.actions.clickRepositoryAt(0);
});
test('should indicate the number of snapshots found', () => {
const { find } = testBed;
expect(find('repositoryDetail.snapshotCount').text()).toEqual('2 snapshots found');
});
});
});
});
});
describe('snapshots', () => {
describe('when there are no snapshots nor repositories', () => {
beforeAll(() => {
httpRequestsMockHelpers.setLoadSnapshotsResponse({ snapshots: [], repositories: [] });
});
beforeEach(async () => {
testBed = await setup();
// @ts-ignore (remove when react 16.9.0 is released)
await act(async () => {
testBed.actions.selectTab('snapshots');
await nextTick(100);
testBed.component.update();
});
});
test('should display an empty prompt', () => {
const { exists } = testBed;
expect(exists('emptyPrompt')).toBe(true);
});
test('should invite the user to first register a repository', () => {
const { find, exists } = testBed;
expect(find('emptyPrompt.title').text()).toBe(
`You don't have any snapshots or repositories yet`
);
expect(exists('emptyPrompt.registerRepositoryButton')).toBe(true);
});
});
describe('when there are no snapshots but has some repository', () => {
beforeEach(async () => {
httpRequestsMockHelpers.setLoadSnapshotsResponse({
snapshots: [],
repositories: ['my-repo'],
});
testBed = await setup();
// @ts-ignore (remove when react 16.9.0 is released)
await act(async () => {
testBed.actions.selectTab('snapshots');
await nextTick(2000);
testBed.component.update();
});
});
test('should display an empty prompt', () => {
const { find, exists } = testBed;
expect(exists('emptyPrompt')).toBe(true);
expect(find('emptyPrompt.title').text()).toBe(`You don't have any snapshots yet`);
});
test('should have a link to the snapshot documentation', () => {
const { exists } = testBed;
expect(exists('emptyPrompt.documentationLink')).toBe(true);
});
});
describe('when there are snapshots and repositories', () => {
const snapshot1 = fixtures.getSnapshot({
repository: REPOSITORY_NAME,
snapshot: `a${getRandomString()}`,
});
const snapshot2 = fixtures.getSnapshot({
repository: REPOSITORY_NAME,
snapshot: `b${getRandomString()}`,
});
const snapshots = [snapshot1, snapshot2];
beforeEach(async () => {
httpRequestsMockHelpers.setLoadSnapshotsResponse({
snapshots,
repositories: [REPOSITORY_NAME],
errors: {},
});
testBed = await setup();
// @ts-ignore (remove when react 16.9.0 is released)
await act(async () => {
testBed.actions.selectTab('snapshots');
await nextTick(2000);
testBed.component.update();
});
});
test('should list them in the table', async () => {
const { table } = testBed;
const { tableCellsValues } = table.getMetaData('snapshotTable');
tableCellsValues.forEach((row, i) => {
const snapshot = snapshots[i];
expect(row).toEqual([
snapshot.snapshot, // Snapshot
REPOSITORY_NAME, // Repository
formatDate(snapshot.startTimeInMillis), // Date created
`${Math.ceil(snapshot.durationInMillis / 1000).toString()}s`, // Duration
snapshot.indices.length.toString(), // Indices
snapshot.shards.total.toString(), // Shards
snapshot.shards.failed.toString(), // Failed shards
]);
});
});
test('each row should have a link to the repository', async () => {
const { component, find, exists, table, router } = testBed;
const { rows } = table.getMetaData('snapshotTable');
const repositoryLink = findTestSubject(rows[0].reactWrapper, 'repositoryLink');
const { href } = repositoryLink.props();
// @ts-ignore (remove when react 16.9.0 is released)
await act(async () => {
router.navigateTo(href!);
await nextTick();
component.update();
});
// Make sure that we navigated to the repository list
// and opened the detail panel for the repository
expect(exists('snapshotList')).toBe(false);
expect(exists('repositoryList')).toBe(true);
expect(exists('repositoryDetail')).toBe(true);
expect(find('repositoryDetail.title').text()).toBe(REPOSITORY_NAME);
});
test('should have a button to reload the snapshots', async () => {
const { component, exists, actions } = testBed;
const totalRequests = server.requests.length;
expect(exists('reloadButton')).toBe(true);
// @ts-ignore (remove when react 16.9.0 is released)
await act(async () => {
actions.clickReloadButton();
await nextTick();
component.update();
});
expect(server.requests.length).toBe(totalRequests + 1);
expect(server.requests[server.requests.length - 1].url).toBe(`${API_BASE_PATH}snapshots`);
});
describe('detail panel', () => {
beforeEach(async () => {
httpRequestsMockHelpers.setGetSnapshotResponse(snapshot1);
});
test('should show the detail when clicking on a snapshot', async () => {
const { exists, actions } = testBed;
expect(exists('snapshotDetail')).toBe(false);
await actions.clickSnapshotAt(0);
expect(exists('snapshotDetail')).toBe(true);
});
test('should show a loading while fetching the snapshot', async () => {
const { find, exists, actions } = testBed;
// By providing undefined, the "loading section" will be displayed
httpRequestsMockHelpers.setGetSnapshotResponse(undefined);
await actions.clickSnapshotAt(0);
expect(exists('snapshotDetail.sectionLoading')).toBe(true);
expect(find('snapshotDetail.sectionLoading').text()).toEqual('Loading snapshot…');
});
describe('on mount', () => {
beforeEach(async () => {
await testBed.actions.clickSnapshotAt(0);
});
test('should set the correct title', async () => {
const { find } = testBed;
expect(find('snapshotDetail.detailTitle').text()).toEqual(snapshot1.snapshot);
});
test('should have a link to show the repository detail', async () => {
const { component, exists, find, router } = testBed;
expect(exists('snapshotDetail.repositoryLink')).toBe(true);
const { href } = find('snapshotDetail.repositoryLink').props();
// @ts-ignore (remove when react 16.9.0 is released)
await act(async () => {
router.navigateTo(href);
await nextTick();
component.update();
});
// Make sure that we navigated to the repository list
// and opened the detail panel for the repository
expect(exists('snapshotList')).toBe(false);
expect(exists('repositoryList')).toBe(true);
expect(exists('repositoryDetail')).toBe(true);
expect(find('repositoryDetail.title').text()).toBe(REPOSITORY_NAME);
});
test('should have a button to close the detail panel', () => {
const { find, exists } = testBed;
expect(exists('snapshotDetail.closeButton')).toBe(true);
find('snapshotDetail.closeButton').simulate('click');
expect(exists('snapshotDetail')).toBe(false);
});
describe('tabs', () => {
test('should have 2 tabs', () => {
const { find } = testBed;
const tabs = find('snapshotDetail.tab');
expect(tabs.length).toBe(2);
expect(tabs.map(t => t.text())).toEqual(['Summary', 'Failed indices (0)']);
});
test('should have the default tab set on "Summary"', () => {
const { find } = testBed;
const tabs = find('snapshotDetail.tab');
const selectedTab = find('snapshotDetail').find('.euiTab-isSelected');
expect(selectedTab.instance()).toBe(tabs.at(0).instance());
});
describe('summary tab', () => {
test('should set the correct summary values', () => {
const { find } = testBed;
expect(find('snapshotDetail.version.value').text()).toBe(
`${snapshot1.version} / ${snapshot1.versionId}`
);
expect(find('snapshotDetail.uuid.value').text()).toBe(snapshot1.uuid);
expect(find('snapshotDetail.state.value').text()).toBe('Snapshot complete');
expect(find('snapshotDetail.includeGlobalState.value').text()).toBe('Yes');
expect(find('snapshotDetail.indices.title').text()).toBe(
`Indices (${snapshot1.indices.length})`
);
expect(find('snapshotDetail.indices.value').text()).toBe(
snapshot1.indices.join('')
);
expect(find('snapshotDetail.startTime.value').text()).toBe(
formatDate(snapshot1.startTimeInMillis)
);
expect(find('snapshotDetail.endTime.value').text()).toBe(
formatDate(snapshot1.endTimeInMillis)
);
});
test('should indicate the different snapshot states', async () => {
const { find, actions } = testBed;
// We need to click back and forth between the first table row (0) and the second row (1)
// in order to trigger the HTTP request that loads the snapshot with the new state.
// This varible keeps track of it.
let itemIndexToClickOn = 1;
const setSnapshotStateAndUpdateDetail = async (state: string) => {
const updatedSnapshot = { ...snapshot1, state };
httpRequestsMockHelpers.setGetSnapshotResponse(updatedSnapshot);
await actions.clickSnapshotAt(itemIndexToClickOn); // click another snapshot to trigger the HTTP call
};
const expectMessageForSnapshotState = async (
state: string,
expectedMessage: string
) => {
await setSnapshotStateAndUpdateDetail(state);
const stateMessage = find('snapshotDetail.state.value').text();
try {
expect(stateMessage).toBe(expectedMessage);
} catch {
throw new Error(
`Expected snapshot state message "${expectedMessage}" for state "${state}, but got "${stateMessage}".`
);
}
itemIndexToClickOn = itemIndexToClickOn ? 0 : 1;
};
const mapStateToMessage = {
[SNAPSHOT_STATE.IN_PROGRESS]: 'Taking snapshot…',
[SNAPSHOT_STATE.FAILED]: 'Snapshot failed',
[SNAPSHOT_STATE.PARTIAL]: 'Partial failure ',
[SNAPSHOT_STATE.INCOMPATIBLE]: 'Incompatible version ',
};
// Call sequencially each state and verify that the message is ok
return Object.entries(mapStateToMessage).reduce((promise, [state, message]) => {
return promise.then(async () => expectMessageForSnapshotState(state, message));
}, Promise.resolve());
});
});
describe('failed indices tab', () => {
test('should display a message when snapshot created successfully', () => {
const { find, actions } = testBed;
actions.selectSnapshotDetailTab('failedIndices');
expect(find('snapshotDetail.content').text()).toBe(
'All indices were stored successfully.'
);
});
test('should display a message when snapshot in progress ', async () => {
const { find, actions } = testBed;
const updatedSnapshot = { ...snapshot1, state: 'IN_PROGRESS' };
httpRequestsMockHelpers.setGetSnapshotResponse(updatedSnapshot);
await actions.clickSnapshotAt(1); // click another snapshot to trigger the HTTP call
actions.selectSnapshotDetailTab('failedIndices');
expect(find('snapshotDetail.content').text()).toBe('Snapshot is being created.');
});
});
});
});
});
describe('when there are failed indices', () => {
const failure1 = fixtures.getIndexFailure();
const failure2 = fixtures.getIndexFailure();
const indexFailures = [failure1, failure2];
beforeEach(async () => {
const updatedSnapshot = { ...snapshot1, indexFailures };
httpRequestsMockHelpers.setGetSnapshotResponse(updatedSnapshot);
await testBed.actions.clickSnapshotAt(0);
testBed.actions.selectSnapshotDetailTab('failedIndices');
});
test('should update the tab label', () => {
const { find } = testBed;
expect(
find('snapshotDetail.tab')
.at(1)
.text()
).toBe(`Failed indices (${indexFailures.length})`);
});
test('should display the failed indices', () => {
const { find } = testBed;
const expected = indexFailures.map(failure => failure.index);
const found = find('snapshotDetail.indexFailure.index').map(wrapper => wrapper.text());
expect(find('snapshotDetail.indexFailure').length).toBe(2);
expect(found).toEqual(expected);
});
test('should detail the failure for each index', () => {
const { find } = testBed;
const index0Failure = find('snapshotDetail.indexFailure').at(0);
const failuresFound = findTestSubject(index0Failure, 'failure');
expect(failuresFound.length).toBe(failure1.failures.length);
const failure0 = failuresFound.at(0);
const shardText = findTestSubject(failure0, 'shard').text();
const reasonText = findTestSubject(failure0, 'reason').text();
const [mockedFailure] = failure1.failures;
expect(shardText).toBe(`Shard ${mockedFailure.shard_id}`);
expect(reasonText).toBe(`${mockedFailure.status}: ${mockedFailure.reason}`);
});
});
});
});
});

View file

@ -0,0 +1,302 @@
/*
* 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.
*/
import { act } from 'react-dom/test-utils';
import { INVALID_NAME_CHARS } from '../../public/app/services/validation/validate_repository';
import { getRepository } from '../../test/fixtures';
import { RepositoryType } from '../../common/types';
import { setupEnvironment, pageHelpers, nextTick } from './helpers';
import { RepositoryAddTestBed } from './helpers/repository_add.helpers';
const { setup } = pageHelpers.repositoryAdd;
const repositoryTypes = ['fs', 'url', 'source', 'azure', 'gcs', 's3', 'hdfs'];
jest.mock('ui/i18n', () => {
const I18nContext = ({ children }: any) => children;
return { I18nContext };
});
// We need to skip the tests until react 16.9.0 is released
// which supports asynchronous code inside act()
describe.skip('<RepositoryAdd />', () => {
let testBed: RepositoryAddTestBed;
const { server, httpRequestsMockHelpers } = setupEnvironment();
afterAll(() => {
server.restore();
});
describe('on component mount', () => {
beforeEach(async () => {
httpRequestsMockHelpers.setLoadRepositoryTypesResponse(repositoryTypes);
testBed = await setup();
});
test('should set the correct page title', () => {
const { exists, find } = testBed;
expect(exists('pageTitle')).toBe(true);
expect(find('pageTitle').text()).toEqual('Register repository');
});
test('should indicate that the repository types are loading', () => {
const { exists, find } = testBed;
expect(exists('sectionLoading')).toBe(true);
expect(find('sectionLoading').text()).toBe('Loading repository types…');
});
test('should not let the user go to the next step if some fields are missing', () => {
const { form, actions } = testBed;
actions.clickNextButton();
expect(form.getErrorsMessages()).toEqual([
'Repository name is required.',
'Type is required.',
]);
});
});
describe('when no repository types are not found', () => {
beforeEach(async () => {
httpRequestsMockHelpers.setLoadRepositoryTypesResponse([]);
testBed = await setup();
await nextTick();
testBed.component.update();
});
test('should show an error callout ', async () => {
const { find, exists } = testBed;
expect(exists('noRepositoryTypesError')).toBe(true);
expect(find('noRepositoryTypesError').text()).toContain('No repository types available');
});
});
describe('when repository types are found', () => {
beforeEach(async () => {
httpRequestsMockHelpers.setLoadRepositoryTypesResponse(repositoryTypes);
testBed = await setup();
await nextTick();
testBed.component.update();
});
test('should have 1 card for each repository type', () => {
const { exists } = testBed;
repositoryTypes.forEach(type => {
const testSubject: any = `${type}RepositoryType`;
try {
expect(exists(testSubject)).toBe(true);
} catch {
throw new Error(`Repository type "${type}" was not found.`);
}
});
});
});
describe('form validations', () => {
beforeEach(async () => {
httpRequestsMockHelpers.setLoadRepositoryTypesResponse(repositoryTypes);
testBed = await setup();
await nextTick();
testBed.component.update();
});
describe('name (step 1)', () => {
it('should not allow spaces in the name', () => {
const { form, actions } = testBed;
form.setInputValue('nameInput', 'with space');
actions.clickNextButton();
expect(form.getErrorsMessages()).toContain('Spaces are not allowed in the name.');
});
it('should not allow invalid characters', () => {
const { form, actions } = testBed;
const expectErrorForChar = (char: string) => {
form.setInputValue('nameInput', `with${char}`);
actions.clickNextButton();
try {
expect(form.getErrorsMessages()).toContain(
`Character "${char}" is not allowed in the name.`
);
} catch {
throw new Error(`Invalid character ${char} did not display an error.`);
}
};
INVALID_NAME_CHARS.forEach(expectErrorForChar);
});
});
describe('settings (step 2)', () => {
const typeToErrorMessagesMap: Record<string, string[]> = {
fs: ['Location is required.'],
url: ['URL is required.'],
s3: ['Bucket is required.'],
gcs: ['Bucket is required.'],
hdfs: ['URI is required.'],
};
test('should validate required repository settings', async () => {
const { component, actions, form } = testBed;
form.setInputValue('nameInput', 'my-repo');
const selectRepoTypeAndExpectErrors = async (type: RepositoryType) => {
actions.selectRepositoryType(type);
actions.clickNextButton();
// @ts-ignore (remove when react 16.9.0 is released)
await act(async () => {
actions.clickSubmitButton();
await nextTick();
component.update();
});
const expectedErrors = typeToErrorMessagesMap[type];
const errorsFound = form.getErrorsMessages();
expectedErrors.forEach(error => {
try {
expect(errorsFound).toContain(error);
} catch {
throw new Error(
`Expected "${error}" not found in form. Got "${JSON.stringify(errorsFound)}"`
);
}
});
// @ts-ignore (remove when react 16.9.0 is released)
await act(async () => {
actions.clickBackButton();
await nextTick(100);
component.update();
});
};
await selectRepoTypeAndExpectErrors('fs');
await selectRepoTypeAndExpectErrors('url');
await selectRepoTypeAndExpectErrors('s3');
await selectRepoTypeAndExpectErrors('gcs');
await selectRepoTypeAndExpectErrors('hdfs');
});
});
});
describe('form payload & api errors', () => {
const repository = getRepository();
beforeEach(async () => {
httpRequestsMockHelpers.setLoadRepositoryTypesResponse(repositoryTypes);
testBed = await setup();
});
describe('not source only', () => {
beforeEach(() => {
// Fill step 1 required fields and go to step 2
testBed.form.setInputValue('nameInput', repository.name);
testBed.actions.selectRepositoryType(repository.type);
testBed.actions.clickNextButton();
});
test('should send the correct payload', async () => {
const { form, actions } = testBed;
// Fill step 2
form.setInputValue('locationInput', repository.settings.location);
form.selectCheckBox('compressToggle');
// @ts-ignore (remove when react 16.9.0 is released)
await act(async () => {
actions.clickSubmitButton();
await nextTick();
});
const latestRequest = server.requests[server.requests.length - 1];
expect(latestRequest.requestBody).toEqual(
JSON.stringify({
name: repository.name,
type: repository.type,
settings: {
location: repository.settings.location,
compress: true,
},
})
);
});
test('should surface the API errors from the "save" HTTP request', async () => {
const { component, form, actions, find, exists } = testBed;
form.setInputValue('locationInput', repository.settings.location);
form.selectCheckBox('compressToggle');
const error = {
status: 400,
error: 'Bad request',
message: 'Repository payload is invalid',
};
httpRequestsMockHelpers.setSaveRepositoryResponse(undefined, { body: error });
// @ts-ignore (remove when react 16.9.0 is released)
await act(async () => {
actions.clickSubmitButton();
await nextTick();
component.update();
});
expect(exists('saveRepositoryApiError')).toBe(true);
expect(find('saveRepositoryApiError').text()).toContain(error.message);
});
});
describe('source only', () => {
beforeEach(() => {
// Fill step 1 required fields and go to step 2
testBed.form.setInputValue('nameInput', repository.name);
testBed.actions.selectRepositoryType(repository.type);
testBed.form.selectCheckBox('sourceOnlyToggle'); // toggle source
testBed.actions.clickNextButton();
});
test('should send the correct payload', async () => {
const { form, actions } = testBed;
// Fill step 2
form.setInputValue('locationInput', repository.settings.location);
// @ts-ignore (remove when react 16.9.0 is released)
await act(async () => {
actions.clickSubmitButton();
await nextTick();
});
const latestRequest = server.requests[server.requests.length - 1];
expect(latestRequest.requestBody).toEqual(
JSON.stringify({
name: repository.name,
type: 'source',
settings: {
delegateType: repository.type,
location: repository.settings.location,
},
})
);
});
});
});
});

View file

@ -0,0 +1,274 @@
/*
* 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.
*/
import { act } from 'react-dom/test-utils';
import { setupEnvironment, pageHelpers, nextTick, TestBed, getRandomString } from './helpers';
import { RepositoryForm } from '../../public/app/components/repository_form';
import { RepositoryEditTestSubjects } from './helpers/repository_edit.helpers';
import { RepositoryAddTestSubjects } from './helpers/repository_add.helpers';
import { REPOSITORY_EDIT } from './helpers/constant';
const { setup } = pageHelpers.repositoryEdit;
const { setup: setupRepositoryAdd } = pageHelpers.repositoryAdd;
jest.mock('ui/i18n', () => {
const I18nContext = ({ children }: any) => children;
return { I18nContext };
});
// We need to skip the tests until react 16.9.0 is released
// which supports asynchronous code inside act()
describe.skip('<RepositoryEdit />', () => {
let testBed: TestBed<RepositoryEditTestSubjects>;
let testBedRepositoryAdd: TestBed<RepositoryAddTestSubjects>;
const { server, httpRequestsMockHelpers } = setupEnvironment();
afterAll(() => {
server.restore();
});
describe('on component mount', () => {
beforeEach(async () => {
httpRequestsMockHelpers.setGetRepositoryResponse({
repository: REPOSITORY_EDIT,
snapshots: { count: 0 },
});
testBed = await setup();
// @ts-ignore (remove when react 16.9.0 is released)
await act(async () => {
await nextTick();
testBed.component.update();
});
});
test('should set the correct page title', () => {
const { find } = testBed;
expect(find('repositoryForm.stepTwo.title').text()).toBe(
`'${REPOSITORY_EDIT.name}' settings`
);
});
/**
* As the "edit" repository component uses the same form underneath that
* the "create" repository, we won't test it again but simply make sure that
* the same form component is indeed shared between the 2 app sections.
*/
test('should use the same Form component as the "<RepositoryAdd />" section', async () => {
httpRequestsMockHelpers.setLoadRepositoryTypesResponse(['fs', 'url']);
testBedRepositoryAdd = await setupRepositoryAdd();
const formEdit = testBed.component.find(RepositoryForm);
const formAdd = testBedRepositoryAdd.component.find(RepositoryForm);
expect(formEdit.length).toBe(1);
expect(formAdd.length).toBe(1);
});
});
describe('should populate the correct values', () => {
const mountComponentWithMock = async (repository: any) => {
httpRequestsMockHelpers.setGetRepositoryResponse({
repository: { name: getRandomString(), ...repository },
snapshots: { count: 0 },
});
testBed = await setup();
// @ts-ignore (remove when react 16.9.0 is released)
await act(async () => {
await nextTick();
testBed.component.update();
});
};
it('fs repository', async () => {
const settings = {
location: getRandomString(),
compress: true,
chunkSize: getRandomString(),
maxSnapshotBytesPerSec: getRandomString(),
maxRestoreBytesPerSec: getRandomString(),
readonly: true,
};
await mountComponentWithMock({ type: 'fs', settings });
const { find } = testBed;
expect(find('locationInput').props().defaultValue).toBe(settings.location);
expect(find('compressToggle').props().checked).toBe(settings.compress);
expect(find('chunkSizeInput').props().defaultValue).toBe(settings.chunkSize);
expect(find('maxSnapshotBytesInput').props().defaultValue).toBe(
settings.maxSnapshotBytesPerSec
);
expect(find('maxRestoreBytesInput').props().defaultValue).toBe(
settings.maxRestoreBytesPerSec
);
expect(find('readOnlyToggle').props().checked).toBe(settings.readonly);
});
it('readonly repository', async () => {
const settings = {
url: 'https://elastic.co',
};
await mountComponentWithMock({ type: 'url', settings });
const { find } = testBed;
expect(find('schemeSelect').props().value).toBe('https');
expect(find('urlInput').props().defaultValue).toBe('elastic.co');
});
it('azure repository', async () => {
const settings = {
client: getRandomString(),
container: getRandomString(),
basePath: getRandomString(),
compress: true,
chunkSize: getRandomString(),
readonly: true,
locationMode: getRandomString(),
maxRestoreBytesPerSec: getRandomString(),
maxSnapshotBytesPerSec: getRandomString(),
};
await mountComponentWithMock({ type: 'azure', settings });
const { find } = testBed;
expect(find('clientInput').props().defaultValue).toBe(settings.client);
expect(find('containerInput').props().defaultValue).toBe(settings.container);
expect(find('basePathInput').props().defaultValue).toBe(settings.basePath);
expect(find('compressToggle').props().checked).toBe(settings.compress);
expect(find('chunkSizeInput').props().defaultValue).toBe(settings.chunkSize);
expect(find('maxSnapshotBytesInput').props().defaultValue).toBe(
settings.maxSnapshotBytesPerSec
);
expect(find('maxRestoreBytesInput').props().defaultValue).toBe(
settings.maxRestoreBytesPerSec
);
expect(find('locationModeSelect').props().value).toBe(settings.locationMode);
expect(find('readOnlyToggle').props().checked).toBe(settings.readonly);
});
it('gcs repository', async () => {
const settings = {
bucket: getRandomString(),
client: getRandomString(),
basePath: getRandomString(),
compress: true,
chunkSize: getRandomString(),
readonly: true,
maxRestoreBytesPerSec: getRandomString(),
maxSnapshotBytesPerSec: getRandomString(),
};
await mountComponentWithMock({ type: 'gcs', settings });
const { find } = testBed;
expect(find('clientInput').props().defaultValue).toBe(settings.client);
expect(find('bucketInput').props().defaultValue).toBe(settings.bucket);
expect(find('basePathInput').props().defaultValue).toBe(settings.basePath);
expect(find('compressToggle').props().checked).toBe(settings.compress);
expect(find('chunkSizeInput').props().defaultValue).toBe(settings.chunkSize);
expect(find('maxSnapshotBytesInput').props().defaultValue).toBe(
settings.maxSnapshotBytesPerSec
);
expect(find('maxRestoreBytesInput').props().defaultValue).toBe(
settings.maxRestoreBytesPerSec
);
expect(find('readOnlyToggle').props().checked).toBe(settings.readonly);
});
it('hdfs repository', async () => {
const settings = {
delegateType: 'gcs',
uri: 'hdfs://elastic.co',
path: getRandomString(),
loadDefault: true,
compress: true,
chunkSize: getRandomString(),
readonly: true,
'security.principal': getRandomString(),
maxRestoreBytesPerSec: getRandomString(),
maxSnapshotBytesPerSec: getRandomString(),
conf1: 'foo',
conf2: 'bar',
};
await mountComponentWithMock({ type: 'hdfs', settings });
const { find } = testBed;
expect(find('uriInput').props().defaultValue).toBe('elastic.co');
expect(find('pathInput').props().defaultValue).toBe(settings.path);
expect(find('loadDefaultsToggle').props().checked).toBe(settings.loadDefault);
expect(find('compressToggle').props().checked).toBe(settings.compress);
expect(find('chunkSizeInput').props().defaultValue).toBe(settings.chunkSize);
expect(find('securityPrincipalInput').props().defaultValue).toBe(
settings['security.principal']
);
expect(find('maxSnapshotBytesInput').props().defaultValue).toBe(
settings.maxSnapshotBytesPerSec
);
expect(find('maxRestoreBytesInput').props().defaultValue).toBe(
settings.maxRestoreBytesPerSec
);
expect(find('readOnlyToggle').props().checked).toBe(settings.readonly);
const codeEditor = testBed.component.find('EuiCodeEditor');
expect(JSON.parse(codeEditor.props().value as string)).toEqual({
loadDefault: true,
conf1: 'foo',
conf2: 'bar',
});
});
it('s3 repository', async () => {
const settings = {
bucket: getRandomString(),
client: getRandomString(),
basePath: getRandomString(),
compress: true,
chunkSize: getRandomString(),
serverSideEncryption: true,
bufferSize: getRandomString(),
cannedAcl: getRandomString(),
storageClass: getRandomString(),
readonly: true,
maxRestoreBytesPerSec: getRandomString(),
maxSnapshotBytesPerSec: getRandomString(),
};
await mountComponentWithMock({ type: 's3', settings });
const { find } = testBed;
expect(find('clientInput').props().defaultValue).toBe(settings.client);
expect(find('bucketInput').props().defaultValue).toBe(settings.bucket);
expect(find('basePathInput').props().defaultValue).toBe(settings.basePath);
expect(find('compressToggle').props().checked).toBe(settings.compress);
expect(find('chunkSizeInput').props().defaultValue).toBe(settings.chunkSize);
expect(find('serverSideEncryptionToggle').props().checked).toBe(
settings.serverSideEncryption
);
expect(find('bufferSizeInput').props().defaultValue).toBe(settings.bufferSize);
expect(find('cannedAclSelect').props().value).toBe(settings.cannedAcl);
expect(find('storageClassSelect').props().value).toBe(settings.storageClass);
expect(find('maxSnapshotBytesInput').props().defaultValue).toBe(
settings.maxSnapshotBytesPerSec
);
expect(find('maxRestoreBytesInput').props().defaultValue).toBe(
settings.maxRestoreBytesPerSec
);
expect(find('readOnlyToggle').props().checked).toBe(settings.readonly);
});
});
});

View file

@ -91,7 +91,7 @@ export const App: React.FunctionComponent = () => {
const sectionsRegex = sections.join('|');
return (
<div>
<div data-test-subj="snapshotRestoreApp">
<Switch>
<Route exact path={`${BASE_PATH}/add_repository`} component={RepositoryAdd} />
<Route exact path={`${BASE_PATH}/edit_repository/:name*`} component={RepositoryEdit} />

View file

@ -147,7 +147,7 @@ export const RepositoryDeleteProvider: React.FunctionComponent<Props> = ({ child
)
}
buttonColor="danger"
data-test-subj="srDeleteRepositoryConfirmationModal"
data-test-subj="deleteRepositoryConfirmation"
>
{isSingle ? (
<p>

View file

@ -115,7 +115,11 @@ export const RepositoryForm: React.FunctionComponent<Props> = ({
);
return (
<EuiForm isInvalid={hasValidationErrors} error={validationErrors}>
<EuiForm
isInvalid={hasValidationErrors}
error={validationErrors}
data-test-subj="repositoryForm"
>
{currentStep === 1 && !isEditing ? renderStepOne() : renderStepTwo()}
</EuiForm>
);

View file

@ -125,6 +125,7 @@ export const RepositoryFormStepOne: React.FunctionComponent<Props> = ({
name: e.target.value,
});
}}
data-test-subj="nameInput"
/>
</EuiFormRow>
</EuiDescribedFormGroup>
@ -159,6 +160,7 @@ export const RepositoryFormStepOne: React.FunctionComponent<Props> = ({
onClick: () => onTypeChange(type),
isSelected: isSelectedType,
}}
data-test-subj={`${type}RepositoryType`}
/>
</EuiFlexItem>
);
@ -331,6 +333,7 @@ export const RepositoryFormStepOne: React.FunctionComponent<Props> = ({
});
}
}}
data-test-subj="sourceOnlyToggle"
/>
</EuiFormRow>
</EuiDescribedFormGroup>
@ -343,7 +346,7 @@ export const RepositoryFormStepOne: React.FunctionComponent<Props> = ({
fill
iconType="arrowRight"
iconSide="right"
data-test-subj="srRepositoryFormNextButton"
data-test-subj="nextButton"
>
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.nextButtonLabel"

View file

@ -66,7 +66,7 @@ export const RepositoryFormStepTwo: React.FunctionComponent<Props> = ({
<EuiFlexGroup alignItems="center" justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiTitle size="m">
<h2>
<h2 data-test-subj="title">
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.fields.settingsTitle"
defaultMessage="{repositoryName} settings"
@ -135,7 +135,7 @@ export const RepositoryFormStepTwo: React.FunctionComponent<Props> = ({
color="primary"
iconType="arrowLeft"
onClick={onBack}
data-test-subj="srRepositoryFormSubmitButton"
data-test-subj="backButton"
>
<FormattedMessage
id="xpack.snapshotRestore.repositoryForm.backButtonLabel"
@ -150,7 +150,7 @@ export const RepositoryFormStepTwo: React.FunctionComponent<Props> = ({
iconType="check"
onClick={onSave}
fill={isManagedRepository ? false : true}
data-test-subj="srRepositoryFormSubmitButton"
data-test-subj="submitButton"
isLoading={isSaving}
>
{isSaving ? savingLabel : saveLabel}
@ -195,11 +195,11 @@ export const RepositoryFormStepTwo: React.FunctionComponent<Props> = ({
};
return (
<Fragment>
<div data-test-subj="stepTwo">
{renderSettings()}
{renderFormValidationError()}
{renderSaveError()}
{renderActions()}
</Fragment>
</div>
);
};

View file

@ -100,6 +100,7 @@ export const AzureSettings: React.FunctionComponent<Props> = ({
client: e.target.value,
});
}}
data-test-subj="clientInput"
/>
</EuiFormRow>
</EuiDescribedFormGroup>
@ -145,6 +146,7 @@ export const AzureSettings: React.FunctionComponent<Props> = ({
container: e.target.value,
});
}}
data-test-subj="containerInput"
/>
</EuiFormRow>
</EuiDescribedFormGroup>
@ -190,6 +192,7 @@ export const AzureSettings: React.FunctionComponent<Props> = ({
basePath: e.target.value,
});
}}
data-test-subj="basePathInput"
/>
</EuiFormRow>
</EuiDescribedFormGroup>
@ -235,6 +238,7 @@ export const AzureSettings: React.FunctionComponent<Props> = ({
compress: e.target.checked,
});
}}
data-test-subj="compressToggle"
/>
</EuiFormRow>
</EuiDescribedFormGroup>
@ -281,6 +285,7 @@ export const AzureSettings: React.FunctionComponent<Props> = ({
chunkSize: e.target.value,
});
}}
data-test-subj="chunkSizeInput"
/>
</EuiFormRow>
</EuiDescribedFormGroup>
@ -327,6 +332,7 @@ export const AzureSettings: React.FunctionComponent<Props> = ({
maxSnapshotBytesPerSec: e.target.value,
});
}}
data-test-subj="maxSnapshotBytesInput"
/>
</EuiFormRow>
</EuiDescribedFormGroup>
@ -373,6 +379,7 @@ export const AzureSettings: React.FunctionComponent<Props> = ({
maxRestoreBytesPerSec: e.target.value,
});
}}
data-test-subj="maxRestoreBytesInput"
/>
</EuiFormRow>
</EuiDescribedFormGroup>
@ -420,6 +427,7 @@ export const AzureSettings: React.FunctionComponent<Props> = ({
});
}}
fullWidth
data-test-subj="locationModeSelect"
/>
</EuiFormRow>
</EuiDescribedFormGroup>
@ -466,6 +474,7 @@ export const AzureSettings: React.FunctionComponent<Props> = ({
readonly: locationMode === locationModeOptions[1].value ? true : e.target.checked,
});
}}
data-test-subj="readOnlyToggle"
/>
</EuiFormRow>
</EuiDescribedFormGroup>

View file

@ -96,6 +96,7 @@ export const FSSettings: React.FunctionComponent<Props> = ({
location: e.target.value,
});
}}
data-test-subj="locationInput"
/>
</EuiFormRow>
</EuiDescribedFormGroup>
@ -141,6 +142,7 @@ export const FSSettings: React.FunctionComponent<Props> = ({
compress: e.target.checked,
});
}}
data-test-subj="compressToggle"
/>
</EuiFormRow>
</EuiDescribedFormGroup>
@ -187,6 +189,7 @@ export const FSSettings: React.FunctionComponent<Props> = ({
chunkSize: e.target.value,
});
}}
data-test-subj="chunkSizeInput"
/>
</EuiFormRow>
</EuiDescribedFormGroup>
@ -233,6 +236,7 @@ export const FSSettings: React.FunctionComponent<Props> = ({
maxSnapshotBytesPerSec: e.target.value,
});
}}
data-test-subj="maxSnapshotBytesInput"
/>
</EuiFormRow>
</EuiDescribedFormGroup>
@ -279,6 +283,7 @@ export const FSSettings: React.FunctionComponent<Props> = ({
maxRestoreBytesPerSec: e.target.value,
});
}}
data-test-subj="maxRestoreBytesInput"
/>
</EuiFormRow>
</EuiDescribedFormGroup>
@ -324,6 +329,7 @@ export const FSSettings: React.FunctionComponent<Props> = ({
readonly: e.target.checked,
});
}}
data-test-subj="readOnlyToggle"
/>
</EuiFormRow>
</EuiDescribedFormGroup>

View file

@ -87,6 +87,7 @@ export const GCSSettings: React.FunctionComponent<Props> = ({
client: e.target.value,
});
}}
data-test-subj="clientInput"
/>
</EuiFormRow>
</EuiDescribedFormGroup>
@ -132,6 +133,7 @@ export const GCSSettings: React.FunctionComponent<Props> = ({
bucket: e.target.value,
});
}}
data-test-subj="bucketInput"
/>
</EuiFormRow>
</EuiDescribedFormGroup>
@ -177,6 +179,7 @@ export const GCSSettings: React.FunctionComponent<Props> = ({
basePath: e.target.value,
});
}}
data-test-subj="basePathInput"
/>
</EuiFormRow>
</EuiDescribedFormGroup>
@ -222,6 +225,7 @@ export const GCSSettings: React.FunctionComponent<Props> = ({
compress: e.target.checked,
});
}}
data-test-subj="compressToggle"
/>
</EuiFormRow>
</EuiDescribedFormGroup>
@ -268,6 +272,7 @@ export const GCSSettings: React.FunctionComponent<Props> = ({
chunkSize: e.target.value,
});
}}
data-test-subj="chunkSizeInput"
/>
</EuiFormRow>
</EuiDescribedFormGroup>
@ -314,6 +319,7 @@ export const GCSSettings: React.FunctionComponent<Props> = ({
maxSnapshotBytesPerSec: e.target.value,
});
}}
data-test-subj="maxSnapshotBytesInput"
/>
</EuiFormRow>
</EuiDescribedFormGroup>
@ -360,6 +366,7 @@ export const GCSSettings: React.FunctionComponent<Props> = ({
maxRestoreBytesPerSec: e.target.value,
});
}}
data-test-subj="maxRestoreBytesInput"
/>
</EuiFormRow>
</EuiDescribedFormGroup>
@ -405,6 +412,7 @@ export const GCSSettings: React.FunctionComponent<Props> = ({
readonly: e.target.checked,
});
}}
data-test-subj="readOnlyToggle"
/>
</EuiFormRow>
</EuiDescribedFormGroup>

View file

@ -109,6 +109,7 @@ export const HDFSSettings: React.FunctionComponent<Props> = ({
});
}}
aria-describedby="hdfsRepositoryUriDescription hdfsRepositoryUriProtocolDescription"
data-test-subj="uriInput"
/>
</EuiFormRow>
</EuiDescribedFormGroup>
@ -154,6 +155,7 @@ export const HDFSSettings: React.FunctionComponent<Props> = ({
path: e.target.value,
});
}}
data-test-subj="pathInput"
/>
</EuiFormRow>
</EuiDescribedFormGroup>
@ -199,6 +201,7 @@ export const HDFSSettings: React.FunctionComponent<Props> = ({
loadDefaults: e.target.checked,
});
}}
data-test-subj="loadDefaultsToggle"
/>
</EuiFormRow>
</EuiDescribedFormGroup>
@ -244,6 +247,7 @@ export const HDFSSettings: React.FunctionComponent<Props> = ({
compress: e.target.checked,
});
}}
data-test-subj="compressToggle"
/>
</EuiFormRow>
</EuiDescribedFormGroup>
@ -290,6 +294,7 @@ export const HDFSSettings: React.FunctionComponent<Props> = ({
chunkSize: e.target.value,
});
}}
data-test-subj="chunkSizeInput"
/>
</EuiFormRow>
</EuiDescribedFormGroup>
@ -335,6 +340,7 @@ export const HDFSSettings: React.FunctionComponent<Props> = ({
'security.principal': e.target.value,
});
}}
data-test-subj="securityPrincipalInput"
/>
</EuiFormRow>
</EuiDescribedFormGroup>
@ -434,6 +440,7 @@ export const HDFSSettings: React.FunctionComponent<Props> = ({
setIsConfInvalid(true);
}
}}
data-test-subj="codeEditor"
/>
</EuiFormRow>
</EuiDescribedFormGroup>
@ -480,6 +487,7 @@ export const HDFSSettings: React.FunctionComponent<Props> = ({
maxSnapshotBytesPerSec: e.target.value,
});
}}
data-test-subj="maxSnapshotBytesInput"
/>
</EuiFormRow>
</EuiDescribedFormGroup>
@ -526,6 +534,7 @@ export const HDFSSettings: React.FunctionComponent<Props> = ({
maxRestoreBytesPerSec: e.target.value,
});
}}
data-test-subj="maxRestoreBytesInput"
/>
</EuiFormRow>
</EuiDescribedFormGroup>
@ -571,6 +580,7 @@ export const HDFSSettings: React.FunctionComponent<Props> = ({
readonly: e.target.checked,
});
}}
data-test-subj="readOnlyToggle"
/>
</EuiFormRow>
</EuiDescribedFormGroup>

View file

@ -137,6 +137,7 @@ export const ReadonlySettings: React.FunctionComponent<Props> = ({
value={selectedScheme}
onChange={e => selectScheme(e.target.value)}
aria-controls="readonlyRepositoryUrlHelp"
data-test-subj="schemeSelect"
/>
</EuiFormRow>
</EuiFlexItem>
@ -162,6 +163,7 @@ export const ReadonlySettings: React.FunctionComponent<Props> = ({
url: `${selectedScheme}://${e.target.value}`,
});
}}
data-test-subj="urlInput"
/>
</EuiFormRow>
</EuiFlexItem>

View file

@ -116,6 +116,7 @@ export const S3Settings: React.FunctionComponent<Props> = ({
client: e.target.value,
});
}}
data-test-subj="clientInput"
/>
</EuiFormRow>
</EuiDescribedFormGroup>
@ -161,6 +162,7 @@ export const S3Settings: React.FunctionComponent<Props> = ({
bucket: e.target.value,
});
}}
data-test-subj="bucketInput"
/>
</EuiFormRow>
</EuiDescribedFormGroup>
@ -206,6 +208,7 @@ export const S3Settings: React.FunctionComponent<Props> = ({
basePath: e.target.value,
});
}}
data-test-subj="basePathInput"
/>
</EuiFormRow>
</EuiDescribedFormGroup>
@ -251,6 +254,7 @@ export const S3Settings: React.FunctionComponent<Props> = ({
compress: e.target.checked,
});
}}
data-test-subj="compressToggle"
/>
</EuiFormRow>
</EuiDescribedFormGroup>
@ -297,6 +301,7 @@ export const S3Settings: React.FunctionComponent<Props> = ({
chunkSize: e.target.value,
});
}}
data-test-subj="chunkSizeInput"
/>
</EuiFormRow>
</EuiDescribedFormGroup>
@ -342,6 +347,7 @@ export const S3Settings: React.FunctionComponent<Props> = ({
serverSideEncryption: e.target.checked,
});
}}
data-test-subj="serverSideEncryptionToggle"
/>
</EuiFormRow>
</EuiDescribedFormGroup>
@ -389,6 +395,7 @@ export const S3Settings: React.FunctionComponent<Props> = ({
bufferSize: e.target.value,
});
}}
data-test-subj="bufferSizeInput"
/>
</EuiFormRow>
</EuiDescribedFormGroup>
@ -435,6 +442,7 @@ export const S3Settings: React.FunctionComponent<Props> = ({
});
}}
fullWidth
data-test-subj="cannedAclSelect"
/>
</EuiFormRow>
</EuiDescribedFormGroup>
@ -481,6 +489,7 @@ export const S3Settings: React.FunctionComponent<Props> = ({
});
}}
fullWidth
data-test-subj="storageClassSelect"
/>
</EuiFormRow>
</EuiDescribedFormGroup>
@ -527,6 +536,7 @@ export const S3Settings: React.FunctionComponent<Props> = ({
maxSnapshotBytesPerSec: e.target.value,
});
}}
data-test-subj="maxSnapshotBytesInput"
/>
</EuiFormRow>
</EuiDescribedFormGroup>
@ -573,6 +583,7 @@ export const S3Settings: React.FunctionComponent<Props> = ({
maxRestoreBytesPerSec: e.target.value,
});
}}
data-test-subj="maxRestoreBytesInput"
/>
</EuiFormRow>
</EuiDescribedFormGroup>
@ -618,6 +629,7 @@ export const S3Settings: React.FunctionComponent<Props> = ({
readonly: e.target.checked,
});
}}
data-test-subj="readOnlyToggle"
/>
</EuiFormRow>
</EuiDescribedFormGroup>

View file

@ -18,7 +18,7 @@ interface Props {
};
}
export const SectionError: React.FunctionComponent<Props> = ({ title, error }) => {
export const SectionError: React.FunctionComponent<Props> = ({ title, error, ...rest }) => {
const {
error: errorString,
cause, // wrapEsError() on the server adds a "cause" array
@ -26,7 +26,7 @@ export const SectionError: React.FunctionComponent<Props> = ({ title, error }) =
} = error.data;
return (
<EuiCallOut title={title} color="danger" iconType="alert">
<EuiCallOut title={title} color="danger" iconType="alert" {...rest}>
<div>{message || errorString}</div>
{cause && (
<Fragment>

View file

@ -17,6 +17,7 @@ export const SectionLoading: React.FunctionComponent<Props> = ({ children }) =>
<EuiEmptyPrompt
title={<EuiLoadingSpinner size="xl" />}
body={<EuiText color="subdued">{children}</EuiText>}
data-test-subj="sectionLoading"
/>
);
};

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React, { createContext, useContext, useReducer } from 'react';
import React, { createContext, useContext, useReducer, ReactNode } from 'react';
import { render } from 'react-dom';
import { HashRouter } from 'react-router-dom';
@ -19,37 +19,45 @@ export { BASE_PATH as CLIENT_BASE_PATH } from './constants';
*/
let DependenciesContext: React.Context<AppDependencies>;
export const useAppDependencies = () => useContext<AppDependencies>(DependenciesContext);
export const setAppDependencies = (deps: AppDependencies) => {
DependenciesContext = createContext<AppDependencies>(deps);
return DependenciesContext.Provider;
};
const ReactApp: React.FunctionComponent<AppDependencies> = ({ core, plugins }) => {
export const useAppDependencies = () => {
if (!DependenciesContext) {
throw new Error(`The app dependencies Context hasn't been set.
Use the "setAppDependencies()" method when bootstrapping the app.`);
}
return useContext<AppDependencies>(DependenciesContext);
};
const getAppProviders = (deps: AppDependencies) => {
const {
i18n: { Context: I18nContext },
} = core;
} = deps.core;
const appDependencies: AppDependencies = {
core,
plugins,
};
// Create App dependencies context and get its provider
const AppDependenciesProvider = setAppDependencies(deps);
DependenciesContext = createContext<AppDependencies>(appDependencies);
return (
return ({ children }: { children: ReactNode }) => (
<I18nContext>
<HashRouter>
<DependenciesContext.Provider value={appDependencies}>
<AppStateProvider value={useReducer(reducer, initialState)}>
<App />
</AppStateProvider>
</DependenciesContext.Provider>
<AppDependenciesProvider value={deps}>
<AppStateProvider value={useReducer(reducer, initialState)}>{children}</AppStateProvider>
</AppDependenciesProvider>
</HashRouter>
</I18nContext>
);
};
export const renderReact = async (
elem: Element,
core: AppCore,
plugins: AppPlugins
): Promise<void> => {
render(<ReactApp core={core} plugins={plugins} />, elem);
export const renderReact = async (elem: Element, core: AppCore, plugins: AppPlugins) => {
const Providers = getAppProviders({ core, plugins });
render(
<Providers>
<App />
</Providers>,
elem
);
};

View file

@ -53,7 +53,6 @@ export const SnapshotRestoreHome: React.FunctionComponent<RouteComponentProps<Ma
defaultMessage="Snapshots"
/>
),
testSubj: 'srSnapshotsTab',
},
{
id: 'repositories' as Section,
@ -63,7 +62,6 @@ export const SnapshotRestoreHome: React.FunctionComponent<RouteComponentProps<Ma
defaultMessage="Repositories"
/>
),
testSubj: 'srRepositoriesTab',
},
];
@ -82,7 +80,7 @@ export const SnapshotRestoreHome: React.FunctionComponent<RouteComponentProps<Ma
<EuiTitle size="l">
<EuiFlexGroup alignItems="center">
<EuiFlexItem grow={true}>
<h1>
<h1 data-test-subj="appTitle">
<FormattedMessage
id="xpack.snapshotRestore.home.snapshotRestoreTitle"
defaultMessage="Snapshot Repositories"
@ -94,6 +92,7 @@ export const SnapshotRestoreHome: React.FunctionComponent<RouteComponentProps<Ma
href={documentationLinksService.getRepositoryTypeDocUrl()}
target="_blank"
iconType="help"
data-test-subj="documentationLink"
>
<FormattedMessage
id="xpack.snapshotRestore.home.snapshotRestoreDocsLinkText"
@ -121,7 +120,7 @@ export const SnapshotRestoreHome: React.FunctionComponent<RouteComponentProps<Ma
onClick={() => onSectionChange(tab.id)}
isSelected={tab.id === section}
key={tab.id}
data-test-subject={tab.testSubj}
data-test-subj="tab"
>
{tab.name}
</EuiTab>

View file

@ -200,9 +200,11 @@ const RepositoryDetailsUi: React.FunctionComponent<Props> = ({
</h3>
</EuiTitle>
<EuiSpacer size="s" />
{type === REPOSITORY_TYPES.source
? textService.getRepositoryTypeName(type, repository.settings.delegateType)
: textService.getRepositoryTypeName(type)}
<span data-test-subj="repositoryType">
{type === REPOSITORY_TYPES.source
? textService.getRepositoryTypeName(type, repository.settings.delegateType)
: textService.getRepositoryTypeName(type)}
</span>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
@ -211,6 +213,7 @@ const RepositoryDetailsUi: React.FunctionComponent<Props> = ({
href={documentationLinksService.getRepositoryTypeDocUrl(type)}
target="_blank"
iconType="help"
data-test-subj="documentationLink"
>
<FormattedMessage
id="xpack.snapshotRestore.repositoryDetails.repositoryTypeDocLink"
@ -229,7 +232,7 @@ const RepositoryDetailsUi: React.FunctionComponent<Props> = ({
</h3>
</EuiTitle>
<EuiSpacer size="s" />
{renderSnapshotCount()}
<span data-test-subj="snapshotCount">{renderSnapshotCount()}</span>
<EuiSpacer size="l" />
<TypeDetails repository={repository} />
<EuiHorizontalRule />
@ -305,7 +308,12 @@ const RepositoryDetailsUi: React.FunctionComponent<Props> = ({
) : (
<Fragment>
<EuiSpacer size="m" />
<EuiButton onClick={verifyRepository} color="primary" isLoading={isLoadingVerification}>
<EuiButton
onClick={verifyRepository}
color="primary"
isLoading={isLoadingVerification}
data-test-subj="verifyRepositoryButton"
>
<FormattedMessage
id="xpack.snapshotRestore.repositoryDetails.verifyButtonLabel"
defaultMessage="Verify repository"
@ -392,20 +400,20 @@ const RepositoryDetailsUi: React.FunctionComponent<Props> = ({
return (
<EuiFlyout
onClose={onClose}
data-test-subj="srRepositoryDetailsFlyout"
data-test-subj="repositoryDetail"
aria-labelledby="srRepositoryDetailsFlyoutTitle"
size="m"
maxWidth={400}
>
<EuiFlyoutHeader>
<EuiTitle size="m">
<h2 id="srRepositoryDetailsFlyoutTitle" data-test-subj="srRepositoryDetailsFlyoutTitle">
<h2 id="srRepositoryDetailsFlyoutTitle" data-test-subj="title">
{repositoryName}
</h2>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody data-test-subj="srRepositoryDetailsContent">{renderBody()}</EuiFlyoutBody>
<EuiFlyoutBody data-test-subj="content">{renderBody()}</EuiFlyoutBody>
<EuiFlyoutFooter>{renderFooter()}</EuiFlyoutFooter>
</EuiFlyout>

View file

@ -121,7 +121,7 @@ export const RepositoryList: React.FunctionComponent<RouteComponentProps<MatchPa
})}
fill
iconType="plusInCircle"
data-test-subj="srRepositoriesEmptyPromptAddButton"
data-test-subj="registerRepositoryButton"
>
<FormattedMessage
id="xpack.snapshotRestore.addRepositoryButtonLabel"
@ -129,6 +129,7 @@ export const RepositoryList: React.FunctionComponent<RouteComponentProps<MatchPa
/>
</EuiButton>
}
data-test-subj="emptyPrompt"
/>
);
} else {
@ -144,7 +145,7 @@ export const RepositoryList: React.FunctionComponent<RouteComponentProps<MatchPa
}
return (
<Fragment>
<section data-test-subj="repositoryList">
{repositoryName ? (
<RepositoryDetails
repositoryName={repositoryName}
@ -153,6 +154,6 @@ export const RepositoryList: React.FunctionComponent<RouteComponentProps<MatchPa
/>
) : null}
{content}
</Fragment>
</section>
);
};

View file

@ -63,6 +63,7 @@ const RepositoryTableUi: React.FunctionComponent<Props> = ({
<EuiLink
onClick={() => trackUiMetric(UIM_REPOSITORY_SHOW_DETAILS_CLICK)}
href={openRepositoryDetailsUrl(name)}
data-test-subj="repositoryLink"
>
{name}
</EuiLink>
@ -118,6 +119,7 @@ const RepositoryTableUi: React.FunctionComponent<Props> = ({
iconType="pencil"
color="primary"
href={`#${BASE_PATH}/edit_repository/${name}`}
data-test-subj="editRepositoryButton"
/>
</EuiToolTip>
);
@ -152,7 +154,7 @@ const RepositoryTableUi: React.FunctionComponent<Props> = ({
)}
iconType="trash"
color="danger"
data-test-subj="srRepositoryListDeleteActionButton"
data-test-subj="deleteRepositoryButton"
onClick={() => deleteRepositoryPrompt([name], onRepositoryDeleted)}
isDisabled={Boolean(name === managedRepository)}
/>
@ -236,7 +238,12 @@ const RepositoryTableUi: React.FunctionComponent<Props> = ({
toolsRight: (
<EuiFlexGroup gutterSize="m" justifyContent="spaceAround">
<EuiFlexItem>
<EuiButton color="secondary" iconType="refresh" onClick={reload}>
<EuiButton
color="secondary"
iconType="refresh"
onClick={reload}
data-test-subj="reloadButton"
>
<FormattedMessage
id="xpack.snapshotRestore.repositoryList.table.reloadRepositoriesButton"
defaultMessage="Reload"
@ -250,7 +257,7 @@ const RepositoryTableUi: React.FunctionComponent<Props> = ({
})}
fill
iconType="plusInCircle"
data-test-subj="srRepositoriesAddButton"
data-test-subj="registerRepositoryButton"
>
<FormattedMessage
id="xpack.snapshotRestore.repositoryList.addRepositoryButtonLabel"
@ -296,11 +303,12 @@ const RepositoryTableUi: React.FunctionComponent<Props> = ({
pagination={pagination}
isSelectable={true}
rowProps={() => ({
'data-test-subj': 'srRepositoryListTableRow',
'data-test-subj': 'row',
})}
cellProps={(item: any, column: any) => ({
'data-test-subj': `srRepositoryListTableCell-${column.field}`,
'data-test-subj': `cell`,
})}
data-test-subj="repositoryTable"
/>
);
};

View file

@ -84,7 +84,6 @@ const SnapshotDetailsUi: React.FunctionComponent<Props> = ({
defaultMessage="Summary"
/>
),
testSubj: 'srSnapshotDetailsSummaryTab',
},
{
id: TAB_FAILURES,
@ -95,7 +94,6 @@ const SnapshotDetailsUi: React.FunctionComponent<Props> = ({
values={{ failuresCount: indexFailures.length }}
/>
),
testSubj: 'srSnapshotDetailsFailuresTab',
},
];
@ -111,7 +109,7 @@ const SnapshotDetailsUi: React.FunctionComponent<Props> = ({
}}
isSelected={tab.id === activeTab}
key={tab.id}
data-test-subject={tab.testSubj}
data-test-subj="tab"
>
{tab.name}
</EuiTab>
@ -172,7 +170,7 @@ const SnapshotDetailsUi: React.FunctionComponent<Props> = ({
iconType="cross"
flush="left"
onClick={onClose}
data-test-subj="srSnapshotDetailsFlyoutCloseButton"
data-test-subj="closeButton"
>
<FormattedMessage
id="xpack.snapshotRestore.snapshotDetails.closeButtonLabel"
@ -187,7 +185,7 @@ const SnapshotDetailsUi: React.FunctionComponent<Props> = ({
return (
<EuiFlyout
onClose={onClose}
data-test-subj="srSnapshotDetailsFlyout"
data-test-subj="snapshotDetail"
aria-labelledby="srSnapshotDetailsFlyoutTitle"
size="m"
maxWidth={400}
@ -196,7 +194,7 @@ const SnapshotDetailsUi: React.FunctionComponent<Props> = ({
<EuiFlexGroup direction="column" gutterSize="none">
<EuiFlexItem>
<EuiTitle size="m">
<h2 id="srSnapshotDetailsFlyoutTitle" data-test-subj="srSnapshotDetailsFlyoutTitle">
<h2 id="srSnapshotDetailsFlyoutTitle" data-test-subj="detailTitle">
{snapshotId}
</h2>
</EuiTitle>
@ -205,7 +203,7 @@ const SnapshotDetailsUi: React.FunctionComponent<Props> = ({
<EuiFlexItem>
<EuiText size="s">
<p>
<EuiLink href={linkToRepository(repositoryName)}>
<EuiLink href={linkToRepository(repositoryName)} data-test-subj="repositoryLink">
<FormattedMessage
id="xpack.snapshotRestore.snapshotDetails.repositoryTitle"
defaultMessage="'{repositoryName}' repository"
@ -220,7 +218,7 @@ const SnapshotDetailsUi: React.FunctionComponent<Props> = ({
{tabs}
</EuiFlyoutHeader>
<EuiFlyoutBody data-test-subj="srSnapshotDetailsContent">{content}</EuiFlyoutBody>
<EuiFlyoutBody data-test-subj="content">{content}</EuiFlyoutBody>
<EuiFlyoutFooter>{renderFooter()}</EuiFlyoutFooter>
</EuiFlyout>
);

View file

@ -46,8 +46,8 @@ export const TabFailures: React.SFC<Props> = ({ indexFailures, snapshotState })
const { index, failures } = indexObject;
return (
<div key={index}>
<EuiTitle size="xs">
<div key={index} data-test-subj="indexFailure">
<EuiTitle size="xs" data-test-subj="index">
<h3>{index}</h3>
</EuiTitle>
@ -57,8 +57,8 @@ export const TabFailures: React.SFC<Props> = ({ indexFailures, snapshotState })
const { status, reason, shard_id: shardId } = failure;
return (
<div key={`${shardId}${reason}`}>
<EuiText size="xs">
<div key={`${shardId}${reason}`} data-test-subj="failure">
<EuiText size="xs" data-test-subj="shard">
<p>
<strong>
<FormattedMessage
@ -70,7 +70,7 @@ export const TabFailures: React.SFC<Props> = ({ indexFailures, snapshotState })
</p>
</EuiText>
<EuiCodeBlock paddingSize="s">
<EuiCodeBlock paddingSize="s" data-test-subj="reason">
{status}: {reason}
</EuiCodeBlock>

View file

@ -76,7 +76,6 @@ export const TabSummary: React.SFC<Props> = ({ snapshotDetails }) => {
) : (
<FormattedMessage
id="xpack.snapshotRestore.snapshotDetails.itemIndicesNoneLabel"
data-test-subj="srSnapshotDetailsIndicesNoneTitle"
defaultMessage="-"
/>
);
@ -84,130 +83,102 @@ export const TabSummary: React.SFC<Props> = ({ snapshotDetails }) => {
return (
<EuiDescriptionList textStyle="reverse">
<EuiFlexGroup>
<EuiFlexItem data-test-subj="srSnapshotDetailsVersionItem">
<EuiDescriptionListTitle>
<EuiFlexItem data-test-subj="version">
<EuiDescriptionListTitle data-test-subj="title">
<FormattedMessage
id="xpack.snapshotRestore.snapshotDetails.itemVersionLabel"
data-test-subj="srSnapshotDetailsVersionTitle"
defaultMessage="Version / Version ID"
/>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription
className="eui-textBreakWord"
data-test-subj="srSnapshotDetailsVersionDescription"
>
<EuiDescriptionListDescription className="eui-textBreakWord" data-test-subj="value">
{version} / {versionId}
</EuiDescriptionListDescription>
</EuiFlexItem>
<EuiFlexItem data-test-subj="srSnapshotDetailsIncludeGlobalUuidItem">
<EuiDescriptionListTitle>
<EuiFlexItem data-test-subj="uuid">
<EuiDescriptionListTitle data-test-subj="title">
<FormattedMessage
id="xpack.snapshotRestore.snapshotDetails.itemUuidLabel"
data-test-subj="srSnapshotDetailsUuidTitle"
defaultMessage="UUID"
/>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription
className="eui-textBreakWord"
data-test-subj="srSnapshotDetailUuidDescription"
>
<EuiDescriptionListDescription className="eui-textBreakWord" data-test-subj="value">
{uuid}
</EuiDescriptionListDescription>
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexGroup>
<EuiFlexItem data-test-subj="srSnapshotDetailsStateItem">
<EuiDescriptionListTitle>
<EuiFlexItem data-test-subj="state">
<EuiDescriptionListTitle data-test-subj="title">
<FormattedMessage
id="xpack.snapshotRestore.snapshotDetails.itemStateLabel"
data-test-subj="srSnapshotDetailsStateTitle"
defaultMessage="State"
/>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription
className="eui-textBreakWord"
data-test-subj="srSnapshotDetailStateDescription"
>
<EuiDescriptionListDescription className="eui-textBreakWord" data-test-subj="value">
<SnapshotState state={state} />
</EuiDescriptionListDescription>
</EuiFlexItem>
<EuiFlexItem data-test-subj="srSnapshotDetailsIncludeGlobalStateItem">
<EuiDescriptionListTitle>
<EuiFlexItem data-test-subj="includeGlobalState">
<EuiDescriptionListTitle data-test-subj="title">
<FormattedMessage
id="xpack.snapshotRestore.snapshotDetails.itemIncludeGlobalStateLabel"
data-test-subj="srSnapshotDetailsIncludeGlobalStateTitle"
defaultMessage="Includes global state"
/>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription
className="eui-textBreakWord"
data-test-subj="srSnapshotDetailIncludeGlobalStateDescription"
>
<EuiDescriptionListDescription className="eui-textBreakWord" data-test-subj="value">
{includeGlobalStateToHumanizedMap[includeGlobalState]}
</EuiDescriptionListDescription>
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexGroup>
<EuiFlexItem data-test-subj="srSnapshotDetailsIndicesItem">
<EuiDescriptionListTitle>
<EuiFlexItem data-test-subj="indices">
<EuiDescriptionListTitle data-test-subj="title">
<FormattedMessage
id="xpack.snapshotRestore.snapshotDetails.itemIndicesLabel"
data-test-subj="srSnapshotDetailsIndicesTitle"
defaultMessage="Indices ({indicesCount})"
values={{ indicesCount: indices.length }}
/>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription
className="eui-textBreakWord"
data-test-subj="srSnapshotDetailIndicesDescription"
>
<EuiDescriptionListDescription className="eui-textBreakWord" data-test-subj="value">
<EuiText>{indicesList}</EuiText>
</EuiDescriptionListDescription>
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexGroup>
<EuiFlexItem data-test-subj="srSnapshotDetailsStartTimeItem">
<EuiDescriptionListTitle>
<EuiFlexItem data-test-subj="startTime">
<EuiDescriptionListTitle data-test-subj="title">
<FormattedMessage
id="xpack.snapshotRestore.snapshotDetails.itemStartTimeLabel"
data-test-subj="srSnapshotDetailsStartTimeTitle"
defaultMessage="Start time"
/>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription
className="eui-textBreakWord"
data-test-subj="srSnapshotDetailStartTimeDescription"
>
<EuiDescriptionListDescription className="eui-textBreakWord" data-test-subj="value">
<DataPlaceholder data={startTimeInMillis}>
{formatDate(startTimeInMillis)}
</DataPlaceholder>
</EuiDescriptionListDescription>
</EuiFlexItem>
<EuiFlexItem data-test-subj="srSnapshotDetailsEndTimeItem">
<EuiDescriptionListTitle>
<EuiFlexItem data-test-subj="endTime">
<EuiDescriptionListTitle data-test-subj="title">
<FormattedMessage
id="xpack.snapshotRestore.snapshotDetails.itemEndTimeLabel"
data-test-subj="srSnapshotDetailsEndTimeTitle"
defaultMessage="End time"
/>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription
className="eui-textBreakWord"
data-test-subj="srSnapshotDetailEndTimeDescription"
>
<EuiDescriptionListDescription className="eui-textBreakWord" data-test-subj="value">
{state === SNAPSHOT_STATE.IN_PROGRESS ? (
<EuiLoadingSpinner size="m" />
) : (
@ -220,19 +191,15 @@ export const TabSummary: React.SFC<Props> = ({ snapshotDetails }) => {
</EuiFlexGroup>
<EuiFlexGroup>
<EuiFlexItem data-test-subj="srSnapshotDetailsDurationItem">
<EuiDescriptionListTitle>
<EuiFlexItem data-test-subj="duration">
<EuiDescriptionListTitle data-test-subj="title">
<FormattedMessage
id="xpack.snapshotRestore.snapshotDetails.itemDurationLabel"
data-test-subj="srSnapshotDetailsDurationTitle"
defaultMessage="Duration"
/>
</EuiDescriptionListTitle>
<EuiDescriptionListDescription
className="eui-textBreakWord"
data-test-subj="srSnapshotDetailDurationDescription"
>
<EuiDescriptionListDescription className="eui-textBreakWord" data-test-subj="value">
{state === SNAPSHOT_STATE.IN_PROGRESS ? (
<EuiLoadingSpinner size="m" />
) : (

View file

@ -107,7 +107,7 @@ export const SnapshotList: React.FunctionComponent<RouteComponentProps<MatchPara
<EuiEmptyPrompt
iconType="managementApp"
title={
<h1>
<h1 data-test-subj="title">
<FormattedMessage
id="xpack.snapshotRestore.snapshotList.emptyPrompt.errorRepositoriesTitle"
defaultMessage="Some repositories contain errors"
@ -154,7 +154,7 @@ export const SnapshotList: React.FunctionComponent<RouteComponentProps<MatchPara
<EuiEmptyPrompt
iconType="managementApp"
title={
<h1>
<h1 data-test-subj="title">
<FormattedMessage
id="xpack.snapshotRestore.snapshotList.emptyPrompt.noRepositoriesTitle"
defaultMessage="You don't have any snapshots or repositories yet"
@ -176,7 +176,7 @@ export const SnapshotList: React.FunctionComponent<RouteComponentProps<MatchPara
})}
fill
iconType="plusInCircle"
data-test-subj="srSnapshotsEmptyPromptAddRepositoryButton"
data-test-subj="registerRepositoryButton"
>
<FormattedMessage
id="xpack.snapshotRestore.snapshotList.emptyPrompt.noRepositoriesAddButtonLabel"
@ -186,6 +186,7 @@ export const SnapshotList: React.FunctionComponent<RouteComponentProps<MatchPara
</p>
</Fragment>
}
data-test-subj="emptyPrompt"
/>
);
} else if (snapshots.length === 0) {
@ -193,7 +194,7 @@ export const SnapshotList: React.FunctionComponent<RouteComponentProps<MatchPara
<EuiEmptyPrompt
iconType="managementApp"
title={
<h1>
<h1 data-test-subj="title">
<FormattedMessage
id="xpack.snapshotRestore.snapshotList.emptyPrompt.noSnapshotsTitle"
defaultMessage="You don't have any snapshots yet"
@ -212,7 +213,7 @@ export const SnapshotList: React.FunctionComponent<RouteComponentProps<MatchPara
<EuiLink
href={documentationLinksService.getSnapshotDocUrl()}
target="_blank"
data-test-subj="srSnapshotsEmptyPromptDocLink"
data-test-subj="documentationLink"
>
<FormattedMessage
id="xpack.snapshotRestore.emptyPrompt.noSnapshotsDocLinkText"
@ -223,6 +224,7 @@ export const SnapshotList: React.FunctionComponent<RouteComponentProps<MatchPara
</p>
</Fragment>
}
data-test-subj="emptyPrompt"
/>
);
} else {
@ -272,7 +274,7 @@ export const SnapshotList: React.FunctionComponent<RouteComponentProps<MatchPara
}
return (
<Fragment>
<section data-test-subj="snapshotList">
{repositoryName && snapshotId ? (
<SnapshotDetails
repositoryName={repositoryName}
@ -281,6 +283,6 @@ export const SnapshotList: React.FunctionComponent<RouteComponentProps<MatchPara
/>
) : null}
{content}
</Fragment>
</section>
);
};

View file

@ -48,6 +48,7 @@ export const SnapshotTable: React.FunctionComponent<Props> = ({
<EuiLink
onClick={() => trackUiMetric(UIM_SNAPSHOT_SHOW_DETAILS_CLICK)}
href={openSnapshotDetailsUrl(snapshot.repository, snapshotId)}
data-test-subj="snapshotLink"
>
{snapshotId}
</EuiLink>
@ -61,7 +62,9 @@ export const SnapshotTable: React.FunctionComponent<Props> = ({
truncateText: true,
sortable: true,
render: (repositoryName: string) => (
<EuiLink href={linkToRepository(repositoryName)}>{repositoryName}</EuiLink>
<EuiLink href={linkToRepository(repositoryName)} data-test-subj="repositoryLink">
{repositoryName}
</EuiLink>
),
},
{
@ -153,7 +156,12 @@ export const SnapshotTable: React.FunctionComponent<Props> = ({
const search = {
toolsRight: (
<EuiButton color="secondary" iconType="refresh" onClick={reload}>
<EuiButton
color="secondary"
iconType="refresh"
onClick={reload}
data-test-subj="reloadButton"
>
<FormattedMessage
id="xpack.snapshotRestore.snapshotList.table.reloadSnapshotsButton"
defaultMessage="Reload"
@ -195,11 +203,12 @@ export const SnapshotTable: React.FunctionComponent<Props> = ({
sorting={sorting}
pagination={pagination}
rowProps={() => ({
'data-test-subj': 'srSnapshotListTableRow',
'data-test-subj': 'row',
})}
cellProps={(item: any, column: any) => ({
'data-test-subj': `srSnapshotListTableCell-${column.field}`,
'data-test-subj': 'cell',
})}
data-test-subj="snapshotTable"
/>
);
};

View file

@ -59,6 +59,7 @@ export const RepositoryAdd: React.FunctionComponent<RouteComponentProps> = ({ hi
/>
}
error={saveError}
data-test-subj="saveRepositoryApiError"
/>
) : null;
};
@ -71,7 +72,7 @@ export const RepositoryAdd: React.FunctionComponent<RouteComponentProps> = ({ hi
<EuiPageBody>
<EuiPageContent>
<EuiTitle size="l">
<h1>
<h1 data-test-subj="pageTitle">
<FormattedMessage
id="xpack.snapshotRestore.addRepositoryTitle"
defaultMessage="Register repository"

View file

@ -30,7 +30,7 @@ export const sendRequest = async ({
try {
const response = await httpService.httpClient[method](path, body);
if (!response.data) {
if (typeof response.data === 'undefined') {
throw new Error(response.statusText);
}
@ -44,7 +44,7 @@ export const sendRequest = async ({
};
} catch (e) {
return {
error: e,
error: e.response ? e.response : e,
};
}
};
@ -83,16 +83,7 @@ export const useRequest = ({
uimActionType,
};
let response;
if (timeout) {
[response] = await Promise.all([
sendRequest(requestBody),
new Promise(resolve => setTimeout(resolve, timeout)),
]);
} else {
response = await sendRequest(requestBody);
}
const response = await sendRequest(requestBody);
// Don't update state if an outdated request has resolved.
if (isOutdatedRequest) {

View file

@ -29,6 +29,29 @@ export interface RepositorySettingsValidation {
[key: string]: string[];
}
export const INVALID_NAME_CHARS = ['"', '*', '\\', '<', '|', ',', '>', '/', '?'];
const isStringEmpty = (str: string | null): boolean => {
return str ? !Boolean(str.trim()) : true;
};
const doesStringContainChar = (string: string, char: string | string[]) => {
const chars = Array.isArray(char) ? char : [char];
const total = chars.length;
let containsChar = false;
let charFound: string | null = null;
for (let i = 0; i < total; i++) {
if (string.includes(chars[i])) {
containsChar = true;
charFound = chars[i];
break;
}
}
return { containsChar, charFound };
};
export const validateRepository = (
repository: Repository | EmptyRepository,
validateSettings: boolean = true
@ -56,6 +79,25 @@ export const validateRepository = (
];
}
if (name.includes(' ')) {
validation.errors.name = [
i18n.translate('xpack.snapshotRestore.repositoryValidation.nameValidation.errorSpace', {
defaultMessage: 'Spaces are not allowed in the name.',
}),
];
}
const nameCharValidation = doesStringContainChar(name, INVALID_NAME_CHARS);
if (nameCharValidation.containsChar) {
validation.errors.name = [
i18n.translate('xpack.snapshotRestore.repositoryValidation.nameValidation.invalidCharacter', {
defaultMessage: 'Character "{char}" is not allowed in the name.',
values: { char: nameCharValidation.charFound },
}),
];
}
if (
isStringEmpty(type) ||
(type === REPOSITORY_TYPES.source && isStringEmpty(settings.delegateType))
@ -74,10 +116,6 @@ export const validateRepository = (
return validation;
};
const isStringEmpty = (str: string | null): boolean => {
return str ? !Boolean(str.trim()) : true;
};
const validateRepositorySettings = (
type: RepositoryType | null,
settings: Repository['settings']

View file

@ -0,0 +1,11 @@
/*
* 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.
*/
export const chrome = {
breadcrumbs: {
set() {},
},
};

View file

@ -0,0 +1,7 @@
/*
* 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.
*/
export { chrome } from './chrome';

View file

@ -0,0 +1,8 @@
/*
* 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.
*/
export * from './repository';
export * from './snapshot';

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.
*/
import { getRandomString } from '../../../../test_utils';
import { RepositoryType } from '../../common/types';
const defaultSettings: any = { chunkSize: '10mb', location: '/tmp/es-backups' };
interface Repository {
name: string;
type: RepositoryType;
settings: any;
}
export const getRepository = ({
name = getRandomString(),
type = 'fs' as 'fs',
settings = defaultSettings,
}: Partial<Repository> = {}): Repository => ({
name,
type,
settings,
});

View file

@ -0,0 +1,41 @@
/*
* 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.
*/
import { getRandomString, getRandomNumber } from '../../../../test_utils';
export const getSnapshot = ({
repository = 'my-repo',
snapshot = getRandomString(),
uuid = getRandomString(),
state = 'SUCCESS',
indexFailures = [],
totalIndices = getRandomNumber(),
} = {}) => ({
repository,
snapshot,
uuid,
versionId: 8000099,
version: '8.0.0',
indices: new Array(totalIndices).fill('').map(getRandomString),
includeGlobalState: 1,
state,
startTime: '2019-05-23T06:25:15.896Z',
startTimeInMillis: 1558592715896,
endTime: '2019-05-23T06:25:16.603Z',
endTimeInMillis: 1558592716603,
durationInMillis: 707,
indexFailures,
shards: { total: 3, failed: 0, successful: 3 },
});
export const getIndexFailure = (index = getRandomString()) => ({
index,
failures: new Array(getRandomNumber({ min: 1, max: 5 })).fill('').map(() => ({
status: 400,
reason: getRandomString(),
shard_id: getRandomString(),
})),
});

View file

@ -281,3 +281,12 @@ 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.
#### `router`
An object with the following methods:
##### `navigateTo(url)`
If you need to navigate to a different route inside your test and you are not using the `<Link />` component from `react-router`
in your component, you need to use the `router.navigateTo()` method from the testBed in order to trigger the route change on the `MemoryRouter`.

View file

@ -27,6 +27,15 @@
If you chose "typescript" you will get a Union Type ready to copy and paste in your test file.
</div>
</div>
<div class="form-control">
<label class="form-control__label" for="depthInput">Max parent - child depth</label>
<input class="form-control__input" type="text" id="depthInput" value="2" />
<div class="form-control__helper-text">
The dom traversal "depth" to be returned. In most cases 2 level depth is enough to access all your test subjects.
You can always add manually later other depths in your Typescript union string type.
</div>
</div>
</div>
<div class="form-actions">

View file

@ -33,9 +33,10 @@ const onStopTracking = () => {
document.body.classList.remove('is-tracking');
};
chrome.storage.sync.get(['outputType', 'domTreeRoot'], async ({ outputType, domTreeRoot }) => {
chrome.storage.sync.get(['outputType', 'domTreeRoot', 'depth'], async ({ outputType, domTreeRoot, depth }) => {
const domRootInput = document.getElementById('domRootInput');
const outputTypeSelect = document.getElementById('outputTypeSelect');
const depthInput = document.getElementById('depthInput');
const startTrackButton = document.getElementById('startTrackingButton');
const stopTrackButton = document.getElementById('stopTrackingButton');
@ -53,6 +54,10 @@ chrome.storage.sync.get(['outputType', 'domTreeRoot'], async ({ outputType, domT
domRootInput.value = domTreeRoot;
}
if (depth) {
depthInput.value = depth;
}
document.querySelectorAll('#outputTypeSelect option').forEach((node) => {
if (node.value === outputType) {
node.setAttribute('selected', 'selected');
@ -65,6 +70,13 @@ chrome.storage.sync.get(['outputType', 'domTreeRoot'], async ({ outputType, domT
chrome.storage.sync.set({ domTreeRoot: value });
});
depthInput.addEventListener('change', (e) => {
const { value } = e.target;
if (value) {
chrome.storage.sync.set({ depth: value });
}
});
outputTypeSelect.addEventListener('change', (e) => {
const { value } = e.target;
chrome.storage.sync.set({ outputType: value });

View file

@ -7,7 +7,51 @@
/* eslint-disable no-undef */
(function () {
chrome.storage.sync.get(['domTreeRoot', 'outputType'], ({ domTreeRoot, outputType }) => {
/**
* Go from ['a', 'b', 'c', 'd', 'e']
* To ['a.b.c.d.e', 'a.b.c.d', 'a.b.c', 'a.b']
* @param arr The array to outpu
*/
const outputArray = (arr) => {
const output = [];
let i = 0;
while(i < arr.length - 1) {
const end = i ? i * -1 : undefined;
output.push(arr.slice(0, end).join('.'));
i++;
}
return output;
};
const getAllNestedPathsFromArray = (arr, computedArray = []) => {
// Output the array without skipping any item
let output = [...computedArray, ...outputArray(arr)];
// We remove the "head" and the "tail" of the array (pos 0 and arr.length -1)
// We go from ['a', 'b', 'c', 'd', 'e'] (5 items)
// To 3 modified arrays
// ['a', 'c', 'd', 'e'] => outputArray()
// ['a', 'd', 'e'] => outputArray()
// ['a', 'e'] => outputArray()
let itemsToSkip = arr.length - 2;
if (itemsToSkip > 0) {
while(itemsToSkip) {
const newArray = [...arr];
newArray.splice(1, itemsToSkip);
output = [...output, ...outputArray(newArray)];
itemsToSkip--;
}
}
if (arr.length > 2) {
// Recursively call the function skipping the first array item
return getAllNestedPathsFromArray(arr.slice(1), output);
}
return output.sort();
};
chrome.storage.sync.get(['domTreeRoot', 'outputType', 'depth'], ({ domTreeRoot, outputType, depth = 2 }) => {
const datasetKey = 'testSubj';
if (domTreeRoot && !document.querySelector(domTreeRoot)) {
@ -16,8 +60,6 @@
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}'`;
@ -30,6 +72,13 @@
}, '')
);
const addTestSubject = (testSubject) => {
const subjectDepth = testSubject.split('.').length;
if (subjectDepth <= parseInt(depth, 10)) {
window.__test_utils__.dataTestSubjects.add(testSubject);
}
};
const findTestSubjects = (
node = domTreeRoot ? document.querySelector(domTreeRoot) : document.querySelector('body'),
path = []
@ -38,13 +87,8 @@
// 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;
@ -53,7 +97,13 @@
const pathToString = updatedPath.join('.');
if (pathToString) {
dataTestSubjects.add(pathToString);
// Add the complete nested path ('a.b.c.d')
addTestSubject(pathToString);
// Add each item separately
updatedPath.forEach(addTestSubject);
// Add all the combination ('a.b', 'a.c', 'a.e', ...)
const nestedPaths = getAllNestedPathsFromArray(updatedPath);
nestedPaths.forEach(addTestSubject);
}
return;
@ -65,6 +115,7 @@
};
const output = () => {
const { dataTestSubjects } = window.__test_utils__;
const allTestSubjects = Array.from(dataTestSubjects).sort();
console.log(`------------- TEST SUBJECTS (${allTestSubjects.length}) ------------- `);
@ -79,20 +130,24 @@
// Handler for the clicks on the document to keep tracking
// new test subjects
const documentClicksHandler = () => {
const { dataTestSubjects } = window.__test_utils__;
const total = dataTestSubjects.size;
findTestSubjects();
// Wait to be sure that the DOM has updated
setTimeout(() => {
findTestSubjects();
if (dataTestSubjects.size === total) {
// No new test subject, nothing to output
return;
}
if (dataTestSubjects.size === total) {
// No new test subject, nothing to output
return;
}
output();
}, 500);
output();
};
// Add meta data on the window object
window.__test_utils__ = window.__test_utils__ || { documentClicksHandler, isTracking: false };
window.__test_utils__ = window.__test_utils__ || { documentClicksHandler, isTracking: false, dataTestSubjects: new Set() };
// Handle "click" event on the document to update our test subjects
if (!window.__test_utils__.isTracking) {

View file

@ -7,4 +7,5 @@
if (window.__test_utils__ && window.__test_utils__.isTracking) {
document.removeEventListener('click', window.__test_utils__.documentClicksHandler);
window.__test_utils__.isTracking = false;
window.__test_utils__.dataTestSubjects = new Set();
}

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
export { registerTestBed } from './testbed';
export { getRandomString, nextTick } from './lib';
export * from './testbed';
export * from './lib';
export { findTestSubject } from './find_test_subject';
export { getConfigSchema } from './get_config_schema';

View file

@ -4,5 +4,4 @@
* you may not use this file except in compliance with the Elastic License.
*/
export { getRandomString } from './strings';
export { nextTick } from './utils';
export { nextTick, getRandomString, getRandomNumber } from './utils';

View file

@ -4,4 +4,15 @@
* you may not use this file except in compliance with the Elastic License.
*/
import Chance from 'chance';
const chance = new Chance();
const CHARS_POOL = 'abcdefghijklmnopqrstuvwxyz';
export const nextTick = (time = 0) => new Promise(resolve => setTimeout(resolve, time));
export const getRandomNumber = (range: { min: number; max: number } = { min: 1, max: 20 }) =>
chance.integer(range);
export const getRandomString = (options = {}) =>
`${chance.string({ pool: CHARS_POOL, ...options })}-${Date.now()}`;

View file

@ -5,8 +5,7 @@
*/
import React, { Component, ComponentType } from 'react';
import PropTypes from 'prop-types';
import { MemoryRouter, Route } from 'react-router-dom';
import { MemoryRouter, Route, withRouter } from 'react-router-dom';
import * as H from 'history';
export const WithMemoryRouter = (initialEntries: string[] = ['/'], initialIndex: number = 0) => (
@ -17,28 +16,31 @@ export const WithMemoryRouter = (initialEntries: string[] = ['/'], initialIndex:
</MemoryRouter>
);
export const WithRoute = (componentRoutePath = '/', onRouter = (router: MemoryRouter) => {}) => (
export const WithRoute = (componentRoutePath = '/', onRouter = (router: any) => {}) => (
WrappedComponent: ComponentType
) => {
return class extends Component {
static contextTypes = {
router: PropTypes.object,
};
// Create a class component that will catch the router
// and forward it to our "onRouter()" handler.
const CatchRouter = withRouter(
class extends Component<any> {
componentDidMount() {
const { match, location, history } = this.props;
const router = { route: { match, location }, history };
onRouter(router);
}
componentDidMount() {
const { router } = this.context;
onRouter(router);
render() {
return <WrappedComponent {...this.props} />;
}
}
);
render() {
return (
<Route
path={componentRoutePath}
render={props => <WrappedComponent {...props} {...this.props} />}
/>
);
}
};
return (props: any) => (
<Route
path={componentRoutePath}
render={routerProps => <CatchRouter {...routerProps} {...props} />}
/>
);
};
interface Router {

View file

@ -5,3 +5,4 @@
*/
export { registerTestBed } from './testbed';
export { TestBed, TestBedConfig } from './types';

View file

@ -7,39 +7,63 @@
import React, { ComponentType } from 'react';
import { Store } from 'redux';
import { ReactWrapper } from 'enzyme';
import { act } from 'react-dom/test-utils';
import { mountWithIntl } from '../enzyme_helpers';
import { WithMemoryRouter, WithRoute } from '../router_helpers';
import { WithStore } from '../redux_helpers';
import { MemoryRouterConfig } from './types';
export const mountComponent = (
Component: ComponentType,
memoryRouter: MemoryRouterConfig,
store: Store | null,
props: any
): ReactWrapper => {
interface Config {
Component: ComponentType;
memoryRouter: MemoryRouterConfig;
store: Store | null;
props: any;
onRouter: (router: any) => void;
}
const getCompFromConfig = ({ Component, memoryRouter, store, onRouter }: Config): ComponentType => {
const wrapWithRouter = memoryRouter.wrapComponent !== false;
let Comp;
let Comp: ComponentType = store !== null ? WithStore(store)(Component) : Component;
if (wrapWithRouter) {
const { componentRoutePath, onRouter, initialEntries, initialIndex } = memoryRouter!;
const { componentRoutePath, initialEntries, initialIndex } = memoryRouter!;
// Wrap the componenet with a MemoryRouter and attach it to a react-router <Route />
Comp = WithMemoryRouter(initialEntries, initialIndex)(
WithRoute(componentRoutePath, onRouter)(Component)
WithRoute(componentRoutePath, onRouter)(Comp)
);
// Add the Redux Provider
if (store !== null) {
Comp = WithStore(store)(Comp);
}
} else {
Comp = store !== null ? WithStore(store)(Component) : Component;
}
return mountWithIntl(<Comp {...props} />);
return Comp;
};
export const mountComponentSync = (config: Config): ReactWrapper => {
const Comp = getCompFromConfig(config);
return mountWithIntl(<Comp {...config.props} />);
};
export const mountComponentAsync = async (config: Config): Promise<ReactWrapper> => {
const Comp = getCompFromConfig(config);
/**
* In order for hooks with effects to work in our tests
* we need to wrap the mounting under the new act "async"
* that ships with React 16.9.0
*
* https://github.com/facebook/react/pull/14853
* https://github.com/threepointone/react-act-examples/blob/master/sync.md
*/
let component: ReactWrapper;
// @ts-ignore
await act(async () => {
component = mountWithIntl(<Comp {...config.props} />);
});
// @ts-ignore
return component;
};
export const getJSXComponentWithProps = (Component: ComponentType, props: any) => (

View file

@ -7,7 +7,11 @@
import { ComponentType, ReactWrapper } from 'enzyme';
import { findTestSubject } from '../find_test_subject';
import { reactRouterMock } from '../router_helpers';
import { mountComponent, getJSXComponentWithProps } from './mount_component';
import {
mountComponentSync,
mountComponentAsync,
getJSXComponentWithProps,
} from './mount_component';
import { TestBedConfig, TestBed, SetupFunc } from './types';
const defaultConfig: TestBedConfig = {
@ -48,7 +52,20 @@ export const registerTestBed = <T extends string = string>(
defaultProps = defaultConfig.defaultProps,
memoryRouter = defaultConfig.memoryRouter!,
store = defaultConfig.store,
doMountAsync = false,
} = config || {};
// Keep a reference to the React Router
let router: any;
const onRouter = (_router: any) => {
router = _router;
if (memoryRouter.onRouter) {
memoryRouter.onRouter(_router);
}
};
/**
* 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 :)
@ -56,156 +73,196 @@ 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 (memoryRouter.onRouter) {
memoryRouter.onRouter(reactRouterMock);
}
onRouter(reactRouterMock);
const setup: SetupFunc<T> = props => {
// If a function is provided we execute it
const storeToMount = typeof store === 'function' ? store() : store!;
const mountConfig = {
Component,
memoryRouter,
store: storeToMount,
props: {
...defaultProps,
...props,
},
onRouter,
};
const component = mountComponent(Component, memoryRouter, storeToMount, {
...defaultProps,
...props,
});
if (doMountAsync) {
return mountComponentAsync(mountConfig).then(onComponentMounted);
}
/**
* ----------------------------------------------------------------
* Utils
* ----------------------------------------------------------------
*/
return onComponentMounted(mountComponentSync(mountConfig));
const find: TestBed<T>['find'] = (testSubject: T) => {
const testSubjectToArray = testSubject.split('.');
// ---------------------
return testSubjectToArray.reduce((reactWrapper, subject, i) => {
const target = findTestSubject(reactWrapper, subject);
if (!target.length && i < testSubjectToArray.length - 1) {
function onComponentMounted(component: ReactWrapper) {
/**
* ----------------------------------------------------------------
* Utils
* ----------------------------------------------------------------
*/
const find: TestBed<T>['find'] = (testSubject: T) => {
const testSubjectToArray = testSubject.split('.');
return testSubjectToArray.reduce((reactWrapper, subject, i) => {
const target = findTestSubject(reactWrapper, subject);
if (!target.length && i < testSubjectToArray.length - 1) {
throw new Error(
`Can't access nested test subject "${
testSubjectToArray[i + 1]
}" of unknown node "${subject}"`
);
}
return target;
}, component);
};
const exists: TestBed<T>['exists'] = (testSubject, count = 1) =>
find(testSubject).length === count;
const setProps: TestBed<T>['setProps'] = updatedProps => {
if (memoryRouter.wrapComponent !== false) {
throw new Error(
`Can't access nested test subject "${
testSubjectToArray[i + 1]
}" of unknown node "${subject}"`
'setProps() can only be called on a component **not** wrapped by a router route.'
);
}
return target;
}, component);
};
if (store === null) {
return component.setProps({ ...defaultProps, ...updatedProps });
}
// Update the props on the Redux Provider children
return component.setProps({
children: getJSXComponentWithProps(Component, { ...defaultProps, ...updatedProps }),
});
};
const exists: TestBed<T>['exists'] = (testSubject, count = 1) =>
find(testSubject).length === count;
/**
* ----------------------------------------------------------------
* Forms
* ----------------------------------------------------------------
*/
const setProps: TestBed<T>['setProps'] = updatedProps => {
if (memoryRouter.wrapComponent !== false) {
throw new Error(
'setProps() can only be called on a component **not** wrapped by a router route.'
);
}
if (store === null) {
return component.setProps({ ...defaultProps, ...updatedProps });
}
// Update the props on the Redux Provider children
return component.setProps({
children: getJSXComponentWithProps(Component, { ...defaultProps, ...updatedProps }),
});
};
const setInputValue: TestBed<T>['form']['setInputValue'] = (
input,
value,
isAsync = false
) => {
const formInput = typeof input === 'string' ? find(input) : (input as ReactWrapper);
/**
* ----------------------------------------------------------------
* Forms
* ----------------------------------------------------------------
*/
if (!formInput.length) {
throw new Error(`Input "${input}" was not found.`);
}
formInput.simulate('change', { target: { value } });
component.update();
const setInputValue: TestBed<T>['form']['setInputValue'] = (input, value, isAsync = false) => {
const formInput = typeof input === 'string' ? find(input) : (input as ReactWrapper);
if (!isAsync) {
return;
}
return new Promise(resolve => setTimeout(resolve));
};
formInput.simulate('change', { target: { value } });
component.update();
const selectCheckBox: TestBed<T>['form']['selectCheckBox'] = (
testSubject,
isChecked = true
) => {
const checkBox = find(testSubject);
if (!checkBox.length) {
throw new Error(`"${testSubject}" was not found.`);
}
checkBox.simulate('change', { target: { checked: isChecked } });
};
if (!isAsync) {
return;
}
return new Promise(resolve => setTimeout(resolve));
};
const toggleEuiSwitch: TestBed<T>['form']['toggleEuiSwitch'] = selectCheckBox; // Same API as "selectCheckBox"
const selectCheckBox: TestBed<T>['form']['selectCheckBox'] = (
dataTestSubject,
isChecked = true
) => {
find(dataTestSubject).simulate('change', { target: { checked: isChecked } });
};
const setComboBoxValue: TestBed<T>['form']['setComboBoxValue'] = (
comboBoxTestSubject,
value
) => {
const comboBox = find(comboBoxTestSubject);
const formInput = findTestSubject(comboBox, 'comboBoxSearchInput');
setInputValue(formInput, value);
const toggleEuiSwitch: TestBed<T>['form']['toggleEuiSwitch'] = selectCheckBox; // Same API as "selectCheckBox"
// keyCode 13 === ENTER
comboBox.simulate('keydown', { keyCode: 13 });
component.update();
};
const setComboBoxValue: TestBed<T>['form']['setComboBoxValue'] = (
comboBoxTestSubject,
value
) => {
const comboBox = find(comboBoxTestSubject);
const formInput = findTestSubject(comboBox, 'comboBoxSearchInput');
setInputValue(formInput, value);
const getErrorsMessages: TestBed<T>['form']['getErrorsMessages'] = () => {
const errorMessagesWrappers = component.find('.euiFormErrorText');
return errorMessagesWrappers.map(err => err.text());
};
// keyCode 13 === ENTER
comboBox.simulate('keydown', { keyCode: 13 });
component.update();
};
/**
* ----------------------------------------------------------------
* Tables
* ----------------------------------------------------------------
*/
const getErrorsMessages: TestBed<T>['form']['getErrorsMessages'] = () => {
const errorMessagesWrappers = component.find('.euiFormErrorText');
return errorMessagesWrappers.map(err => err.text());
};
/**
* 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
*/
const getMetaData: TestBed<T>['table']['getMetaData'] = tableTestSubject => {
const table = find(tableTestSubject);
/**
* ----------------------------------------------------------------
* Tables
* ----------------------------------------------------------------
*/
if (!table.length) {
throw new Error(`Eui Table "${tableTestSubject}" not found.`);
}
/**
* 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
*/
const getMetaData: TestBed<T>['table']['getMetaData'] = tableTestSubject => {
const table = find(tableTestSubject);
const rows = table
.find('tr')
.slice(1) // we remove the first row as it is the table header
.map(row => ({
reactWrapper: row,
columns: row.find('td').map(col => ({
reactWrapper: col,
// We can't access the td value with col.text() because
// eui adds an extra div in td on mobile => (.euiTableRowCell__mobileHeader)
value: col.find('.euiTableCellContent').text(),
})),
}));
if (!table.length) {
throw new Error(`Eui Table "${tableTestSubject}" not found.`);
}
// Also output the raw cell values, in the following format: [[td0, td1, td2], [td0, td1, td2]]
const tableCellsValues = rows.map(({ columns }) => columns.map(col => col.value));
return { rows, tableCellsValues };
};
const rows = table
.find('tr')
.slice(1) // we remove the first row as it is the table header
.map(row => ({
reactWrapper: row,
columns: row.find('td').map(col => ({
reactWrapper: col,
// We can't access the td value with col.text() because
// eui adds an extra div in td on mobile => (.euiTableRowCell__mobileHeader)
value: col.find('.euiTableCellContent').text(),
})),
}));
/**
* ----------------------------------------------------------------
* Router
* ----------------------------------------------------------------
*/
const navigateTo = (_url: string) => {
const url =
_url[0] === '#'
? _url.replace('#', '') // remove the beginning hash as the memory router does not understand them
: _url;
router.history.push(url);
};
// Also output the raw cell values, in the following format: [[td0, td1, td2], [td0, td1, td2]]
const tableCellsValues = rows.map(({ columns }) => columns.map(col => col.value));
return { rows, tableCellsValues };
};
return {
component,
exists,
find,
setProps,
table: {
getMetaData,
},
form: {
setInputValue,
selectCheckBox,
toggleEuiSwitch,
setComboBoxValue,
getErrorsMessages,
},
};
return {
component,
exists,
find,
setProps,
table: {
getMetaData,
},
form: {
setInputValue,
selectCheckBox,
toggleEuiSwitch,
setComboBoxValue,
getErrorsMessages,
},
router: {
navigateTo,
},
};
}
};
return setup;

View file

@ -7,7 +7,7 @@
import { Store } from 'redux';
import { ReactWrapper } from 'enzyme';
export type SetupFunc<T> = (props?: any) => TestBed<T>;
export type SetupFunc<T> = (props?: any) => TestBed<T> | Promise<TestBed<T>>;
export interface EuiTableMetaData {
/** Array of rows of the table. Each row exposes its reactWrapper and its columns */
@ -23,7 +23,7 @@ export interface EuiTableMetaData {
tableCellsValues: string[][];
}
export interface TestBed<T> {
export interface TestBed<T = string> {
/** The comonent under test */
component: ReactWrapper;
/**
@ -41,14 +41,14 @@ export interface TestBed<T> {
*
* @example
*
```ts
find('nameInput');
// or more specific,
// "nameInput" is a child of "myForm"
find('myForm.nameInput');
```
```ts
find('nameInput');
// or more specific,
// "nameInput" is a child of "myForm"
find('myForm.nameInput');
```
*/
find: (testSubject: T) => ReactWrapper;
find: (testSubject: T) => ReactWrapper<any>;
/**
* Update the props of the mounted component
*
@ -102,6 +102,12 @@ find('myForm.nameInput');
table: {
getMetaData: (tableTestSubject: T) => EuiTableMetaData;
};
router: {
/**
* Navigate to another React router <Route />
*/
navigateTo: (url: string) => void;
};
}
export interface TestBedConfig {
@ -111,6 +117,8 @@ export interface TestBedConfig {
memoryRouter?: MemoryRouterConfig;
/** An optional redux store. You can also provide a function that returns a store. */
store?: (() => Store) | Store | null;
/* Mount the component asynchronously. When using "hooked" components with _useEffect()_ calls, you need to set this to "true". */
doMountAsync?: boolean;
}
export interface MemoryRouterConfig {

View file

@ -20,3 +20,5 @@ type MethodKeysOf<T> = {
}[keyof T];
type PublicMethodsOf<T> = Pick<T, MethodKeysOf<T>>;
declare module 'axios/lib/adapters/xhr';