[Resolver] restore function to the resolverTest plugin. (#75799)

Restore the resolverTest plugin. This will allow us to run the test plugin and try out Resolver using our mock data access layers. Eventually this could be expanded to support multiple different data access layers. It could even be expanded to allow us to control the data access layer via the browser. Another option: we could export the APIs from the server and use those in this test plugin.

We eventually expect other plugins to use Resolver. This test plugin could allow us to test Resolver via the FTR (separately of the Security Solution.)

This would also be useful for writing tests than use the FTR but which are essentially unit tests. For example: taking screenshots, using the mouse to zoom/pan.

Start using: `yarn start --plugin-path x-pack/test/plugin_functional/plugins/resolver_test/`
This commit is contained in:
Robert Austin 2020-08-25 13:34:29 -04:00 committed by GitHub
parent c634208e4f
commit e9446b2060
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 195 additions and 137 deletions

View file

@ -4,14 +4,22 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { combineEpics } from 'redux-observable';
import { combineEpics, Epic } from 'redux-observable';
import { Action } from 'redux';
import { createTimelineEpic } from '../../timelines/store/timeline/epic';
import { createTimelineFavoriteEpic } from '../../timelines/store/timeline/epic_favorite';
import { createTimelineNoteEpic } from '../../timelines/store/timeline/epic_note';
import { createTimelinePinnedEventEpic } from '../../timelines/store/timeline/epic_pinned_event';
import { createTimelineLocalStorageEpic } from '../../timelines/store/timeline/epic_local_storage';
import { TimelineEpicDependencies } from '../../timelines/store/timeline/types';
export const createRootEpic = <State>() =>
export const createRootEpic = <State>(): Epic<
Action,
Action,
State,
TimelineEpicDependencies<State>
> =>
combineEpics(
createTimelineEpic<State>(),
createTimelineFavoriteEpic<State>(),

View file

@ -13,6 +13,7 @@ import {
Middleware,
Dispatch,
PreloadedState,
CombinedState,
} from 'redux';
import { createEpicMiddleware } from 'redux-observable';
@ -30,6 +31,7 @@ import { Immutable } from '../../../common/endpoint/types';
import { State } from './types';
import { Storage } from '../../../../../../src/plugins/kibana_utils/public';
import { CoreStart } from '../../../../../../src/core/public';
import { TimelineEpicDependencies } from '../../timelines/store/timeline/types';
type ComposeType = typeof compose;
declare global {
@ -56,7 +58,7 @@ export const createStore = (
): Store<State, Action> => {
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
const middlewareDependencies = {
const middlewareDependencies: TimelineEpicDependencies<State> = {
apolloClient$: apolloClient,
kibana$: kibana,
selectAllTimelineQuery: inputsSelectors.globalQueryByIdSelector,
@ -80,7 +82,7 @@ export const createStore = (
)
);
epicMiddleware.run(createRootEpic<State>());
epicMiddleware.run(createRootEpic<CombinedState<State>>());
return store;
};

View file

@ -66,7 +66,7 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S
this.kibanaVersion = initializerContext.env.packageInfo.version;
}
public setup(core: CoreSetup<StartPlugins, PluginStart>, plugins: SetupPlugins) {
public setup(core: CoreSetup<StartPlugins, PluginStart>, plugins: SetupPlugins): PluginSetup {
initTelemetry(plugins.usageCollection, APP_ID);
plugins.home.featureCatalogue.register({
@ -319,7 +319,12 @@ export class Plugin implements IPlugin<PluginSetup, PluginStart, SetupPlugins, S
},
});
return {};
return {
resolver: async () => {
const { resolverPluginSetup } = await import('./resolver');
return resolverPluginSetup();
},
};
}
public start(core: CoreStart, plugins: StartPlugins) {

View file

@ -0,0 +1,30 @@
/*
* 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 { Provider } from 'react-redux';
import { ResolverPluginSetup } from './types';
import { resolverStoreFactory } from './store/index';
import { ResolverWithoutProviders } from './view/resolver_without_providers';
import { noAncestorsTwoChildren } from './data_access_layer/mocks/no_ancestors_two_children';
/**
* These exports are used by the plugin 'resolverTest' defined in x-pack's plugin_functional suite.
*/
/**
* Provide access to Resolver APIs.
*/
export function resolverPluginSetup(): ResolverPluginSetup {
return {
Provider,
storeFactory: resolverStoreFactory,
ResolverWithoutProviders,
mocks: {
dataAccessLayer: {
noAncestorsTwoChildren,
},
},
};
}

View file

@ -11,7 +11,7 @@ import { resolverReducer } from './reducer';
import { resolverMiddlewareFactory } from './middleware';
import { ResolverAction } from './actions';
export const storeFactory = (
export const resolverStoreFactory = (
dataAccessLayer: DataAccessLayer
): Store<ResolverState, ResolverAction> => {
const actionsDenylist: Array<ResolverAction['type']> = ['userMovedPointer'];

View file

@ -7,7 +7,7 @@
import React from 'react';
import { Store, createStore, applyMiddleware } from 'redux';
import { mount, ReactWrapper } from 'enzyme';
import { createMemoryHistory, History as HistoryPackageHistoryInterface } from 'history';
import { History as HistoryPackageHistoryInterface, createMemoryHistory } from 'history';
import { CoreStart } from '../../../../../../../src/core/public';
import { coreMock } from '../../../../../../../src/core/public/mocks';
import { spyMiddlewareFactory } from '../spy_middleware_factory';

View file

@ -9,6 +9,7 @@
import { Store } from 'redux';
import { Middleware, Dispatch } from 'redux';
import { BBox } from 'rbush';
import { Provider } from 'react-redux';
import { ResolverAction } from './store/actions';
import {
ResolverRelatedEvents,
@ -410,7 +411,7 @@ export interface SideEffectSimulator {
/**
* Mocked `SideEffectors`.
*/
mock: jest.Mocked<Omit<SideEffectors, 'ResizeObserver'>> & Pick<SideEffectors, 'ResizeObserver'>;
mock: SideEffectors;
}
/**
@ -532,3 +533,42 @@ export interface SpyMiddleware {
*/
debugActions: () => () => void;
}
/**
* values of this type are exposed by the Security Solution plugin's setup phase.
*/
export interface ResolverPluginSetup {
/**
* Provide access to the instance of the `react-redux` `Provider` that Resolver recognizes.
*/
Provider: typeof Provider;
/**
* Takes a `DataAccessLayer`, which could be a mock one, and returns an redux Store.
* All data acess (e.g. HTTP requests) are done through the store.
*/
storeFactory: (dataAccessLayer: DataAccessLayer) => Store<ResolverState, ResolverAction>;
/**
* The Resolver component without the required Providers.
* You must wrap this component in: `I18nProvider`, `Router` (from react-router,) `KibanaContextProvider`,
* and the `Provider` component provided by this object.
*/
ResolverWithoutProviders: React.MemoExoticComponent<
React.ForwardRefExoticComponent<ResolverProps & React.RefAttributes<unknown>>
>;
/**
* A collection of mock objects that can be used in examples or in testing.
*/
mocks: {
/**
* Mock `DataAccessLayer`s. All of Resolver's HTTP access is provided by a `DataAccessLayer`.
*/
dataAccessLayer: {
/**
* A mock `DataAccessLayer` that returns a tree that has no ancestor nodes but which has 2 children nodes.
*/
noAncestorsTwoChildren: () => { dataAccessLayer: DataAccessLayer };
};
};
}

View file

@ -7,7 +7,7 @@
import React, { useMemo } from 'react';
import { Provider } from 'react-redux';
import { storeFactory } from '../store';
import { resolverStoreFactory } from '../store';
import { StartServices } from '../../types';
import { useKibana } from '../../../../../../src/plugins/kibana_react/public';
import { DataAccessLayer, ResolverProps } from '../types';
@ -24,7 +24,7 @@ export const Resolver = React.memo((props: ResolverProps) => {
]);
const store = useMemo(() => {
return storeFactory(dataAccessLayer);
return resolverStoreFactory(dataAccessLayer);
}, [dataAccessLayer]);
return (

View file

@ -10,9 +10,9 @@ import { Storage } from '../../../../../../../src/plugins/kibana_utils/public';
import { AppApolloClient } from '../../../common/lib/lib';
import { inputsModel } from '../../../common/store/inputs';
import { NotesById } from '../../../common/store/app/model';
import { StartServices } from '../../../types';
import { TimelineModel } from './model';
import { CoreStart } from '../../../../../../../src/core/public';
export interface AutoSavedWarningMsg {
timelineId: string | null;
@ -55,6 +55,6 @@ export interface TimelineEpicDependencies<State> {
selectAllTimelineQuery: () => (state: State, id: string) => inputsModel.GlobalQuery;
selectNotesByIdSelector: (state: State) => NotesById;
apolloClient$: Observable<AppApolloClient>;
kibana$: Observable<StartServices>;
kibana$: Observable<CoreStart>;
storage: Storage;
}

View file

@ -21,6 +21,7 @@ import {
} from '../../triggers_actions_ui/public';
import { SecurityPluginSetup } from '../../security/public';
import { AppFrontendLibs } from './common/lib/lib';
import { ResolverPluginSetup } from './resolver/types';
export interface SetupPlugins {
home: HomePublicPluginSetup;
@ -46,8 +47,9 @@ export type StartServices = CoreStart &
storage: Storage;
};
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface PluginSetup {}
export interface PluginSetup {
resolver: () => Promise<ResolverPluginSetup>;
}
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface PluginStart {}

View file

@ -2,8 +2,13 @@
"id": "resolver_test",
"version": "1.0.0",
"kibanaVersion": "kibana",
"configPath": ["xpack", "resolver_test"],
"requiredPlugins": ["embeddable"],
"configPath": ["xpack", "resolverTest"],
"requiredPlugins": [
"securitySolution"
],
"requiredBundles": [
"kibanaReact"
],
"server": false,
"ui": true
}

View file

@ -4,119 +4,95 @@
* you may not use this file except in compliance with the Elastic License.
*/
import * as React from 'react';
import { Router } from 'react-router-dom';
import React from 'react';
import ReactDOM from 'react-dom';
import { AppMountParameters } from 'kibana/public';
import { I18nProvider } from '@kbn/i18n/react';
import { IEmbeddable } from 'src/plugins/embeddable/public';
import { useEffect } from 'react';
import { AppMountParameters, CoreStart } from 'kibana/public';
import { useMemo } from 'react';
import styled from 'styled-components';
import { I18nProvider } from '@kbn/i18n/react';
import { KibanaContextProvider } from '../../../../../../../../src/plugins/kibana_react/public';
import {
DataAccessLayer,
ResolverPluginSetup,
} from '../../../../../../../plugins/security_solution/public/resolver/types';
/**
* Render the Resolver Test app. Returns a cleanup function.
*/
export function renderApp(
{ element }: AppMountParameters,
embeddable: Promise<IEmbeddable | undefined>
coreStart: CoreStart,
parameters: AppMountParameters,
resolverPluginSetup: ResolverPluginSetup
) {
/**
* The application DOM node should take all available space.
*/
element.style.display = 'flex';
element.style.flexGrow = '1';
parameters.element.style.display = 'flex';
parameters.element.style.flexGrow = '1';
ReactDOM.render(
<I18nProvider>
<AppRoot embeddable={embeddable} />
</I18nProvider>,
element
<AppRoot
coreStart={coreStart}
parameters={parameters}
resolverPluginSetup={resolverPluginSetup}
/>,
parameters.element
);
return () => {
ReactDOM.unmountComponentAtNode(element);
ReactDOM.unmountComponentAtNode(parameters.element);
};
}
const AppRoot = styled(
React.memo(
({
embeddable: embeddablePromise,
className,
}: {
/**
* A promise which resolves to the Resolver embeddable.
*/
embeddable: Promise<IEmbeddable | undefined>;
/**
* A `className` string provided by `styled`
*/
className?: string;
}) => {
/**
* This state holds the reference to the embeddable, once resolved.
*/
const [embeddable, setEmbeddable] = React.useState<IEmbeddable | undefined>(undefined);
/**
* This state holds the reference to the DOM node that will contain the embeddable.
*/
const [renderTarget, setRenderTarget] = React.useState<HTMLDivElement | null>(null);
const AppRoot = React.memo(
({
coreStart,
parameters,
resolverPluginSetup,
}: {
coreStart: CoreStart;
parameters: AppMountParameters;
resolverPluginSetup: ResolverPluginSetup;
}) => {
const {
Provider,
storeFactory,
ResolverWithoutProviders,
mocks: {
dataAccessLayer: { noAncestorsTwoChildren },
},
} = resolverPluginSetup;
const dataAccessLayer: DataAccessLayer = useMemo(
() => noAncestorsTwoChildren().dataAccessLayer,
[noAncestorsTwoChildren]
);
/**
* Keep component state with the Resolver embeddable.
*
* If the reference to the embeddablePromise changes, we ignore the stale promise.
*/
useEffect(() => {
/**
* A promise rejection function that will prevent a stale embeddable promise from being resolved
* as the current eembeddable.
*
* If the embeddablePromise itself changes before the old one is resolved, we cancel and restart this effect.
*/
let cleanUp;
const store = useMemo(() => {
return storeFactory(dataAccessLayer);
}, [storeFactory, dataAccessLayer]);
const cleanupPromise = new Promise<never>((_resolve, reject) => {
cleanUp = reject;
});
return (
<I18nProvider>
<Router history={parameters.history}>
<KibanaContextProvider services={coreStart}>
<Provider store={store}>
<Wrapper>
<ResolverWithoutProviders
databaseDocumentID=""
resolverComponentInstanceID="test"
/>
</Wrapper>
</Provider>
</KibanaContextProvider>
</Router>
</I18nProvider>
);
}
);
/**
* Either set the embeddable in state, or cancel and restart this process.
*/
Promise.race([cleanupPromise, embeddablePromise]).then((value) => {
setEmbeddable(value);
});
/**
* If `embeddablePromise` is changed, the cleanup function is run.
*/
return cleanUp;
}, [embeddablePromise]);
/**
* Render the eembeddable into the DOM node.
*/
useEffect(() => {
if (embeddable && renderTarget) {
embeddable.render(renderTarget);
/**
* If the embeddable or DOM node changes then destroy the old embeddable.
*/
return () => {
embeddable.destroy();
};
}
}, [embeddable, renderTarget]);
return (
<div
className={className}
data-test-subj="resolverEmbeddableContainer"
ref={setRenderTarget}
/>
);
}
)
)`
const Wrapper = styled.div`
/**
* Take all available space.
*/

View file

@ -4,16 +4,16 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { Plugin, CoreSetup } from 'kibana/public';
import { Plugin, CoreSetup, AppMountParameters } from 'kibana/public';
import { i18n } from '@kbn/i18n';
import { IEmbeddable, EmbeddableStart } from '../../../../../../src/plugins/embeddable/public';
import { PluginSetup as SecuritySolutionPluginSetup } from '../../../../../plugins/security_solution/public';
export type ResolverTestPluginSetup = void;
export type ResolverTestPluginStart = void;
export interface ResolverTestPluginSetupDependencies {} // eslint-disable-line @typescript-eslint/no-empty-interface
export interface ResolverTestPluginStartDependencies {
embeddable: EmbeddableStart;
export interface ResolverTestPluginSetupDependencies {
securitySolution: SecuritySolutionPluginSetup;
}
export interface ResolverTestPluginStartDependencies {} // eslint-disable-line @typescript-eslint/no-empty-interface
export class ResolverTestPlugin
implements
@ -23,34 +23,24 @@ export class ResolverTestPlugin
ResolverTestPluginSetupDependencies,
ResolverTestPluginStartDependencies
> {
public setup(core: CoreSetup<ResolverTestPluginStartDependencies>) {
public setup(
core: CoreSetup<ResolverTestPluginStartDependencies, ResolverTestPluginStart>,
setupDependencies: ResolverTestPluginSetupDependencies
) {
core.application.register({
id: 'resolver_test',
title: i18n.translate('xpack.resolver_test.pluginTitle', {
id: 'resolverTest',
title: i18n.translate('xpack.resolverTest.pluginTitle', {
defaultMessage: 'Resolver Test',
}),
mount: async (_context, params) => {
let resolveEmbeddable: (
value: IEmbeddable | undefined | PromiseLike<IEmbeddable | undefined> | undefined
) => void;
mount: async (params: AppMountParameters<unknown>) => {
const startServices = await core.getStartServices();
const [coreStart] = startServices;
const promise = new Promise<IEmbeddable | undefined>((resolve) => {
resolveEmbeddable = resolve;
});
(async () => {
const [, { embeddable }] = await core.getStartServices();
const factory = embeddable.getEmbeddableFactory('resolver');
if (factory) {
resolveEmbeddable!(factory.create({ id: 'test basic render' }));
}
})();
const { renderApp } = await import('./applications/resolver_test');
/**
* Pass a promise which resolves to the Resolver embeddable.
*/
return renderApp(params, promise);
const [{ renderApp }, resolverPluginSetup] = await Promise.all([
import('./applications/resolver_test'),
setupDependencies.securitySolution.resolver(),
]);
return renderApp(coreStart, params, resolverPluginSetup);
},
});
}