Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Endpoint] Host Details Policy Response Panel #63518

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { coreMock } from '../../../../../../../src/core/public/mocks';
import { EndpointPluginStartDependencies } from '../../../plugin';
import { depsStartMock } from './dependencies_start_mock';
import { AppRootProvider } from '../view/app_root_provider';
import { createSpyMiddleware, MiddlewareActionSpyHelper } from '../store/test_utils';

type UiRender = (ui: React.ReactElement, options?: RenderOptions) => RenderResult;

Expand All @@ -23,6 +24,7 @@ export interface AppContextTestRender {
history: ReturnType<typeof createMemoryHistory>;
coreStart: ReturnType<typeof coreMock.createStart>;
depsStart: EndpointPluginStartDependencies;
middlewareSpy: MiddlewareActionSpyHelper;
/**
* A wrapper around `AppRootContext` component. Uses the mocked modules as input to the
* `AppRootContext`
Expand All @@ -45,7 +47,12 @@ export const createAppRootMockRenderer = (): AppContextTestRender => {
const history = createMemoryHistory<never>();
const coreStart = coreMock.createStart({ basePath: '/mock' });
const depsStart = depsStartMock();
const store = appStoreFactory({ coreStart, depsStart });
const middlewareSpy = createSpyMiddleware();
const store = appStoreFactory({
coreStart,
depsStart,
additionalMiddleware: [middlewareSpy.actionSpyMiddleware],
Copy link
Contributor Author

@paul-tavares paul-tavares Apr 15, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@oatkiller could you review and comment on this change?
This goes along with a change to the appStoreMiddleware() (see further below). It enables us to inject the actionSpyMiddleware into the application store for testing purposes.

Let me know your thoughts

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I generally think this makes sense. One thought though:

When looking at the appStoreFactory, I see these comments:

/**
   * Any additional Redux Middlewares
   * (should only be used for testing - example: to inject the action spy middleware)
   */
        // Additional Middleware should go last
        ...additionalMiddleware

Based on those, could we replace:

additionalMiddleware?: Array<ReturnType<typeof substateMiddlewareFactory>>;

with:

actionSpyMiddleware?: WhateverTheTypeShouldBe

Let me know your thoughts

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the comments. Yeah, that makes sense. I assume that would be (from our types.ts) - ReturnType<MiddlewareFactory> so that it is correctly typed for the dispatch signature.

});
const AppWrapper: React.FunctionComponent<{ children: React.ReactElement }> = ({ children }) => (
<AppRootProvider store={store} history={history} coreStart={coreStart} depsStart={depsStart}>
{children}
Expand All @@ -64,6 +71,7 @@ export const createAppRootMockRenderer = (): AppContextTestRender => {
history,
coreStart,
depsStart,
middlewareSpy,
AppWrapper,
render,
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export const uiQueryParams: (
// Removes the `?` from the beginning of query string if it exists
const query = querystring.parse(location.search.slice(1));

const keys: Array<keyof HostIndexUIQueryParams> = ['selected_host'];
const keys: Array<keyof HostIndexUIQueryParams> = ['selected_host', 'show'];

for (const key of keys) {
const value = query[key];
Expand All @@ -58,3 +58,11 @@ export const hasSelectedHost: (state: Immutable<HostListState>) => boolean = cre
return selectedHost !== undefined;
}
);

/** What policy details panel view to show */
export const showView: (state: HostListState) => 'policy_response' | 'details' = createSelector(
uiQueryParams,
searchParams => {
return searchParams.show === 'policy_response' ? 'policy_response' : 'details';
}
);
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { alertMiddlewareFactory } from './alerts/middleware';
import { hostMiddlewareFactory } from './hosts';
import { policyListMiddlewareFactory } from './policy_list';
import { policyDetailsMiddlewareFactory } from './policy_details';
import { GlobalState } from '../types';
import { GlobalState, MiddlewareFactory } from '../types';
import { AppAction } from './action';
import { EndpointPluginStartDependencies } from '../../../plugin';

Expand Down Expand Up @@ -62,10 +62,15 @@ export const appStoreFactory: (middlewareDeps?: {
* Give middleware access to plugin start dependencies.
*/
depsStart: EndpointPluginStartDependencies;
/**
* Any additional Redux Middlewares
* (should only be used for testing - example: to inject the action spy middleware)
*/
additionalMiddleware?: Array<ReturnType<MiddlewareFactory>>;
}) => Store = middlewareDeps => {
let middleware;
if (middlewareDeps) {
const { coreStart, depsStart } = middlewareDeps;
const { coreStart, depsStart, additionalMiddleware = [] } = middlewareDeps;
middleware = composeWithReduxDevTools(
applyMiddleware(
substateMiddlewareFactory(
Expand All @@ -83,7 +88,9 @@ export const appStoreFactory: (middlewareDeps?: {
substateMiddlewareFactory(
globalState => globalState.alertList,
alertMiddlewareFactory(coreStart, depsStart)
)
),
// Additional Middleware should go last
...additionalMiddleware
)
);
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,10 @@ import { policyListMiddlewareFactory } from './middleware';
import { coreMock } from '../../../../../../../../src/core/public/mocks';
import { isOnPolicyListPage, selectIsLoading, urlSearchParams } from './selectors';
import { DepsStartMock, depsStartMock } from '../../mocks';
import {
createSpyMiddleware,
MiddlewareActionSpyHelper,
setPolicyListApiMockImplementation,
} from './test_mock_utils';
import { setPolicyListApiMockImplementation } from './test_mock_utils';
import { INGEST_API_DATASOURCES } from './services/ingest';
import { Immutable } from '../../../../../common/types';
import { createSpyMiddleware, MiddlewareActionSpyHelper } from '../test_utils';

describe('policy list store concerns', () => {
let fakeCoreStart: ReturnType<typeof coreMock.createStart>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,9 @@
*/

import { HttpStart } from 'kibana/public';
import { Dispatch } from 'redux';
import { INGEST_API_DATASOURCES } from './services/ingest';
import { EndpointDocGenerator } from '../../../../../common/generate_data';
import { AppAction, GetPolicyListResponse, GlobalState, MiddlewareFactory } from '../../types';
import { GetPolicyListResponse } from '../../types';

const generator = new EndpointDocGenerator('policy-list');

Expand Down Expand Up @@ -37,115 +36,3 @@ export const setPolicyListApiMockImplementation = (
return Promise.reject(new Error(`MOCK: unknown policy list api: ${path}`));
});
};

/**
* Utilities for testing Redux middleware
*/
export interface MiddlewareActionSpyHelper<S = GlobalState> {
/**
* Returns a promise that is fulfilled when the given action is dispatched or a timeout occurs.
* The use of this method instead of a `sleep()` type of delay should avoid test case instability
* especially when run in a CI environment.
*
* @param actionType
*/
waitForAction: (actionType: AppAction['type']) => Promise<void>;
/**
* A property holding the information around the calls that were processed by the internal
* `actionSpyMiddlware`. This property holds the information typically found in Jets's mocked
* function `mock` property - [see here for more information](https://jestjs.io/docs/en/mock-functions#mock-property)
*
* **Note**: this property will only be set **after* the `actionSpyMiddlware` has been
* initialized (ex. via `createStore()`. Attempting to reference this property before that time
* will throw an error.
* Also - do not hold on to references to this property value if `jest.clearAllMocks()` or
* `jest.resetAllMocks()` is called between usages of the value.
*/
dispatchSpy: jest.Mock<Dispatch<AppAction>>['mock'];
/**
* Redux middleware that enables spying on the action that are dispatched through the store
*/
actionSpyMiddleware: ReturnType<MiddlewareFactory<S>>;
}

/**
* Creates a new instance of middleware action helpers
* Note: in most cases (testing concern specific middleware) this function should be given
* the state type definition, else, the global state will be used.
*
* @example
* // Use in Policy List middleware testing
* const middlewareSpyUtils = createSpyMiddleware<PolicyListState>();
* store = createStore(
* policyListReducer,
* applyMiddleware(
* policyListMiddlewareFactory(fakeCoreStart, depsStart),
* middlewareSpyUtils.actionSpyMiddleware
* )
* );
* // Reference `dispatchSpy` ONLY after creating the store that includes `actionSpyMiddleware`
* const { waitForAction, dispatchSpy } = middlewareSpyUtils;
* //
* // later in test
* //
* it('...', async () => {
* //...
* await waitForAction('serverReturnedPolicyListData');
* // do assertions
* // or check how action was called
* expect(dispatchSpy.calls.length).toBe(2)
* });
*/
export const createSpyMiddleware = <S = GlobalState>(): MiddlewareActionSpyHelper<S> => {
type ActionWatcher = (action: AppAction) => void;

const watchers = new Set<ActionWatcher>();
let spyDispatch: jest.Mock<Dispatch<AppAction>>;

return {
waitForAction: async (actionType: string) => {
// Error is defined here so that we get a better stack trace that points to the test from where it was used
const err = new Error(`action '${actionType}' was not dispatched within the allocated time`);

await new Promise((resolve, reject) => {
const watch: ActionWatcher = action => {
if (action.type === actionType) {
watchers.delete(watch);
clearTimeout(timeout);
resolve();
}
};

// We timeout before jest's default 5s, so that a better error stack is returned
const timeout = setTimeout(() => {
watchers.delete(watch);
reject(err);
}, 4500);
watchers.add(watch);
});
},

get dispatchSpy() {
if (!spyDispatch) {
throw new Error(
'Spy Middleware has not been initialized. Access this property only after using `actionSpyMiddleware` in a redux store'
);
}
return spyDispatch.mock;
},

actionSpyMiddleware: api => {
return next => {
spyDispatch = jest.fn(action => {
next(action);
// loop through the list of watcher (if any) and call them with this action
for (const watch of watchers) {
watch(action);
}
return action;
});
return spyDispatch;
};
},
};
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
/*
* 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 { Dispatch } from 'redux';
import { AppAction, GlobalState, MiddlewareFactory } from '../types';

/**
* Utilities for testing Redux middleware
*/
export interface MiddlewareActionSpyHelper<S = GlobalState, A extends AppAction = AppAction> {
/**
* Returns a promise that is fulfilled when the given action is dispatched or a timeout occurs.
* The `action` will given to the promise `resolve` thus allowing for checks to be done.
* The use of this method instead of a `sleep()` type of delay should avoid test case instability
* especially when run in a CI environment.
*
* @param actionType
*/
waitForAction: <T extends A['type']>(actionType: T) => Promise<A extends { type: T } ? A : never>;
/**
* A property holding the information around the calls that were processed by the internal
* `actionSpyMiddelware`. This property holds the information typically found in Jets's mocked
* function `mock` property - [see here for more information](https://jestjs.io/docs/en/mock-functions#mock-property)
*
* **Note**: this property will only be set **after* the `actionSpyMiddlware` has been
* initialized (ex. via `createStore()`. Attempting to reference this property before that time
* will throw an error.
* Also - do not hold on to references to this property value if `jest.clearAllMocks()` or
* `jest.resetAllMocks()` is called between usages of the value.
*/
dispatchSpy: jest.Mock<Dispatch<A>>['mock'];
/**
* Redux middleware that enables spying on the action that are dispatched through the store
*/
actionSpyMiddleware: ReturnType<MiddlewareFactory<S>>;
}

/**
* Creates a new instance of middleware action helpers
* Note: in most cases (testing concern specific middleware) this function should be given
* the state type definition, else, the global state will be used.
*
* @example
* // Use in Policy List middleware testing
* const middlewareSpyUtils = createSpyMiddleware<PolicyListState>();
* store = createStore(
* policyListReducer,
* applyMiddleware(
* policyListMiddlewareFactory(fakeCoreStart, depsStart),
* middlewareSpyUtils.actionSpyMiddleware
* )
* );
* // Reference `dispatchSpy` ONLY after creating the store that includes `actionSpyMiddleware`
* const { waitForAction, dispatchSpy } = middlewareSpyUtils;
* //
* // later in test
* //
* it('...', async () => {
* //...
* await waitForAction('serverReturnedPolicyListData');
* // do assertions
* // or check how action was called
* expect(dispatchSpy.calls.length).toBe(2)
* });
*/
export const createSpyMiddleware = <
S = GlobalState,
A extends AppAction = AppAction
>(): MiddlewareActionSpyHelper<S, A> => {
type ActionWatcher = (action: A) => void;

const watchers = new Set<ActionWatcher>();
let spyDispatch: jest.Mock<Dispatch<A>>;

return {
waitForAction: async actionType => {
type ResolvedAction = A extends { type: typeof actionType } ? A : never;

// Error is defined here so that we get a better stack trace that points to the test from where it was used
const err = new Error(`action '${actionType}' was not dispatched within the allocated time`);

return new Promise<ResolvedAction>((resolve, reject) => {
const watch: ActionWatcher = action => {
if (action.type === actionType) {
watchers.delete(watch);
clearTimeout(timeout);
resolve(action as ResolvedAction);
}
};

// We timeout before jest's default 5s, so that a better error stack is returned
const timeout = setTimeout(() => {
watchers.delete(watch);
reject(err);
}, 4500);
watchers.add(watch);
});
},

get dispatchSpy() {
if (!spyDispatch) {
throw new Error(
'Spy Middleware has not been initialized. Access this property only after using `actionSpyMiddleware` in a redux store'
);
}
return spyDispatch.mock;
},

actionSpyMiddleware: api => {
return next => {
spyDispatch = jest.fn(action => {
next(action);
// loop through the list of watcher (if any) and call them with this action
for (const watch of watchers) {
watch(action);
}
return action;
});
return spyDispatch;
};
},
};
};
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export interface HostListPagination {
}
export interface HostIndexUIQueryParams {
selected_host?: string;
show?: string;
}

export interface ServerApiError {
Expand Down
Loading