Skip to content

Commit

Permalink
[Security Solution][Resolver] Graph Control Tests and Update Simulato…
Browse files Browse the repository at this point in the history
…r Selectors (#74680)

Co-authored-by: oatkiller <robert.austin@elastic.co>
  • Loading branch information
michaelolo24 and oatkiller authored Aug 13, 2020
1 parent 250a0b1 commit c34e30e
Show file tree
Hide file tree
Showing 7 changed files with 379 additions and 105 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ declare global {
namespace jest {
interface Matchers<R, T> {
toYieldEqualTo(expectedYield: T extends AsyncIterable<infer E> ? E : never): Promise<R>;
toYieldObjectEqualTo(expectedYield: unknown): Promise<R>;
}
}
}
Expand Down Expand Up @@ -57,6 +58,70 @@ expect.extend({
}
}

// Use `pass` as set in the above loop (or initialized to `false`)
// See https://jestjs.io/docs/en/expect#custom-matchers-api and https://jestjs.io/docs/en/expect#thisutils
const message = pass
? () =>
`${this.utils.matcherHint(matcherName, undefined, undefined, options)}\n\n` +
`Expected: not ${this.utils.printExpected(expected)}\n${
this.utils.stringify(expected) !== this.utils.stringify(received[received.length - 1]!)
? `Received: ${this.utils.printReceived(received[received.length - 1])}`
: ''
}`
: () =>
`${this.utils.matcherHint(matcherName, undefined, undefined, options)}\n\nCompared ${
received.length
} yields.\n\n${received
.map(
(next, index) =>
`yield ${index + 1}:\n\n${this.utils.printDiffOrStringify(
expected,
next,
'Expected',
'Received',
this.expand
)}`
)
.join(`\n\n`)}`;

return { message, pass };
},
/**
* A custom matcher that takes an async generator and compares each value it yields to an expected value.
* This uses the same equality logic as `toMatchObject`.
* If any yielded value equals the expected value, the matcher will pass.
* If the generator ends with none of the yielded values matching, it will fail.
*/
async toYieldObjectEqualTo<T>(
this: jest.MatcherContext,
receivedIterable: AsyncIterable<T>,
expected: T
): Promise<{ pass: boolean; message: () => string }> {
// Used in printing out the pass or fail message
const matcherName = 'toSometimesYieldEqualTo';
const options: jest.MatcherHintOptions = {
comment: 'deep equality with any yielded value',
isNot: this.isNot,
promise: this.promise,
};
// The last value received: Used in printing the message
const received: T[] = [];

// Set to true if the test passes.
let pass: boolean = false;

// Async iterate over the iterable
for await (const next of receivedIterable) {
// keep track of all received values. Used in pass and fail messages
received.push(next);
// Use deep equals to compare the value to the expected value
if ((this.equals(next, expected), [this.utils.iterableEquality, this.utils.subsetEquality])) {
// If the value is equal, break
pass = true;
break;
}
}

// Use `pass` as set in the above loop (or initialized to `false`)
// See https://jestjs.io/docs/en/expect#custom-matchers-api and https://jestjs.io/docs/en/expect#thisutils
const message = pass
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ import { spyMiddlewareFactory } from '../spy_middleware_factory';
import { resolverMiddlewareFactory } from '../../store/middleware';
import { resolverReducer } from '../../store/reducer';
import { MockResolver } from './mock_resolver';
import { ResolverState, DataAccessLayer, SpyMiddleware } from '../../types';
import { ResolverState, DataAccessLayer, SpyMiddleware, SideEffectSimulator } from '../../types';
import { ResolverAction } from '../../store/actions';
import { sideEffectSimulatorFactory } from '../../view/side_effect_simulator_factory';

/**
* Test a Resolver instance using jest, enzyme, and a mock data layer.
Expand Down Expand Up @@ -43,6 +44,11 @@ export class Simulator {
* This is used by `debugActions`.
*/
private readonly spyMiddleware: SpyMiddleware;
/**
* Simulator which allows you to explicitly simulate resize events and trigger animation frames
*/
private readonly sideEffectSimulator: SideEffectSimulator;

constructor({
dataAccessLayer,
resolverComponentInstanceID,
Expand Down Expand Up @@ -87,11 +93,14 @@ export class Simulator {
// Used for `KibanaContextProvider`
const coreStart: CoreStart = coreMock.createStart();

this.sideEffectSimulator = sideEffectSimulatorFactory();

// Render Resolver via the `MockResolver` component, using `enzyme`.
this.wrapper = mount(
<MockResolver
resolverComponentInstanceID={this.resolverComponentInstanceID}
history={this.history}
sideEffectSimulator={this.sideEffectSimulator}
store={this.store}
coreStart={coreStart}
databaseDocumentID={databaseDocumentID}
Expand Down Expand Up @@ -149,6 +158,18 @@ export class Simulator {
return this.domNodes(processNodeElementSelector(options));
}

/**
* Return an Enzyme ReactWrapper for any child elements of a specific processNodeElement
*
* @param entityID The entity ID of the proocess node to select in
* @param selector The selector for the child element of the process node
*/
public processNodeChildElements(entityID: string, selector: string): ReactWrapper {
return this.domNodes(
`${processNodeElementSelector({ entityID })} [data-test-subj="${selector}"]`
);
}

/**
* Return the node element with the given `entityID`.
*/
Expand All @@ -174,21 +195,11 @@ export class Simulator {
}

/**
* Return an Enzyme ReactWrapper that includes the Related Events host button for a given process node
*
* @param entityID The entity ID of the proocess node to select in
* This manually runs the animation frames tied to a configurable timestamp in the future
*/
public processNodeRelatedEventButton(entityID: string): ReactWrapper {
return this.domNodes(
`${processNodeElementSelector({ entityID })} [data-test-subj="resolver:submenu:button"]`
);
}

/**
* The items in the submenu that is opened by expanding a node in the map.
*/
public processNodeSubmenuItems(): ReactWrapper {
return this.domNodes('[data-test-subj="resolver:map:node-submenu-item"]');
public runAnimationFramesTimeFromNow(time: number = 0) {
this.sideEffectSimulator.controls.time = time;
this.sideEffectSimulator.controls.provideAnimationFrame();
}

/**
Expand All @@ -202,59 +213,17 @@ export class Simulator {
}

/**
* The element that shows when Resolver is waiting for the graph data.
*/
public graphLoadingElement(): ReactWrapper {
return this.domNodes('[data-test-subj="resolver:graph:loading"]');
}

/**
* The element that shows if Resolver couldn't draw the graph.
*/
public graphErrorElement(): ReactWrapper {
return this.domNodes('[data-test-subj="resolver:graph:error"]');
}

/**
* The element where nodes get drawn.
*/
public graphElement(): ReactWrapper {
return this.domNodes('[data-test-subj="resolver:graph"]');
}

/**
* The titles of the links that select a node in the node list view.
*/
public nodeListNodeLinkText(): ReactWrapper {
return this.domNodes('[data-test-subj="resolver:node-list:node-link:title"]');
}

/**
* The icons in the links that select a node in the node list view.
*/
public nodeListNodeLinkIcons(): ReactWrapper {
return this.domNodes('[data-test-subj="resolver:node-list:node-link:icon"]');
}

/**
* Link rendered in the breadcrumbs of the node detail view. Takes the user to the node list.
*/
public nodeDetailBreadcrumbNodeListLink(): ReactWrapper {
return this.domNodes('[data-test-subj="resolver:node-detail:breadcrumbs:node-list-link"]');
}

/**
* The title element for the node detail view.
* Given a 'data-test-subj' value, it will resolve the react wrapper or undefined if not found
*/
public nodeDetailViewTitle(): ReactWrapper {
return this.domNodes('[data-test-subj="resolver:node-detail:title"]');
public async resolve(selector: string): Promise<ReactWrapper | undefined> {
return this.resolveWrapper(() => this.domNodes(`[data-test-subj="${selector}"]`));
}

/**
* The icon element for the node detail title.
* Given a 'data-test-subj' selector, it will return the domNode
*/
public nodeDetailViewTitleIcon(): ReactWrapper {
return this.domNodes('[data-test-subj="resolver:node-detail:title-icon"]');
public testSubject(selector: string): ReactWrapper {
return this.domNodes(`[data-test-subj="${selector}"]`);
}

/**
Expand Down Expand Up @@ -297,7 +266,7 @@ export class Simulator {
public async resolveWrapper(
wrapperFactory: () => ReactWrapper,
predicate: (wrapper: ReactWrapper) => boolean = (wrapper) => wrapper.length > 0
): Promise<ReactWrapper | void> {
): Promise<ReactWrapper | undefined> {
for await (const wrapper of this.map(wrapperFactory)) {
if (predicate(wrapper)) {
return wrapper;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

/* eslint-disable react/display-name */

import React, { useMemo, useEffect, useState, useCallback } from 'react';
import React, { useEffect, useState, useCallback } from 'react';
import { Router } from 'react-router-dom';
import { I18nProvider } from '@kbn/i18n/react';
import { Provider } from 'react-redux';
Expand All @@ -17,7 +17,6 @@ import { ResolverState, SideEffectSimulator, ResolverProps } from '../../types';
import { ResolverAction } from '../../store/actions';
import { ResolverWithoutProviders } from '../../view/resolver_without_providers';
import { SideEffectContext } from '../../view/side_effect_context';
import { sideEffectSimulatorFactory } from '../../view/side_effect_simulator_factory';

type MockResolverProps = {
/**
Expand All @@ -38,6 +37,10 @@ type MockResolverProps = {
history: React.ComponentProps<typeof Router>['history'];
/** Pass a resolver store. See `storeFactory` and `mockDataAccessLayer` */
store: Store<ResolverState, ResolverAction>;
/**
* Pass the side effect simulator which handles animations and resizing. See `sideEffectSimulatorFactory`
*/
sideEffectSimulator: SideEffectSimulator;
/**
* All the props from `ResolverWithoutStore` can be passed. These aren't defaulted to anything (you might want to test what happens when they aren't present.)
*/
Expand Down Expand Up @@ -66,8 +69,6 @@ export const MockResolver = React.memo((props: MockResolverProps) => {
setResolverElement(element);
}, []);

const simulator: SideEffectSimulator = useMemo(() => sideEffectSimulatorFactory(), []);

// Resize the Resolver element to match the passed in props. Resolver is size dependent.
useEffect(() => {
if (resolverElement) {
Expand All @@ -84,15 +85,15 @@ export const MockResolver = React.memo((props: MockResolverProps) => {
return this;
},
};
simulator.controls.simulateElementResize(resolverElement, size);
props.sideEffectSimulator.controls.simulateElementResize(resolverElement, size);
}
}, [props.rasterWidth, props.rasterHeight, simulator.controls, resolverElement]);
}, [props.rasterWidth, props.rasterHeight, props.sideEffectSimulator.controls, resolverElement]);

return (
<I18nProvider>
<Router history={props.history}>
<KibanaContextProvider services={props.coreStart}>
<SideEffectContext.Provider value={simulator.mock}>
<SideEffectContext.Provider value={props.sideEffectSimulator.mock}>
<Provider store={props.store}>
<ResolverWithoutProviders
ref={resolverRef}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,9 @@ describe('Resolver, when analyzing a tree that has no ancestors and 2 children',
* For example, there might be no loading element at one point, and 1 graph element at one point, but never a single time when there is both 1 graph element and 0 loading elements.
*/
simulator.map(() => ({
graphElements: simulator.graphElement().length,
graphLoadingElements: simulator.graphLoadingElement().length,
graphErrorElements: simulator.graphErrorElement().length,
graphElements: simulator.testSubject('resolver:graph').length,
graphLoadingElements: simulator.testSubject('resolver:graph:loading').length,
graphErrorElements: simulator.testSubject('resolver:graph:error').length,
}))
).toYieldEqualTo({
// it should have 1 graph element, an no error or loading elements.
Expand Down Expand Up @@ -72,8 +72,12 @@ describe('Resolver, when analyzing a tree that has no ancestors and 2 children',
});

it(`should show links to the 3 nodes (with icons) in the node list.`, async () => {
await expect(simulator.map(() => simulator.nodeListNodeLinkText().length)).toYieldEqualTo(3);
await expect(simulator.map(() => simulator.nodeListNodeLinkIcons().length)).toYieldEqualTo(3);
await expect(
simulator.map(() => simulator.testSubject('resolver:node-list:node-link:title').length)
).toYieldEqualTo(3);
await expect(
simulator.map(() => simulator.testSubject('resolver:node-list:node-link:title').length)
).toYieldEqualTo(3);
});

describe("when the second child node's first button has been clicked", () => {
Expand Down Expand Up @@ -131,9 +135,9 @@ describe('Resolver, when analyzing a tree that has two related events for the or
beforeEach(async () => {
await expect(
simulator.map(() => ({
graphElements: simulator.graphElement().length,
graphLoadingElements: simulator.graphLoadingElement().length,
graphErrorElements: simulator.graphErrorElement().length,
graphElements: simulator.testSubject('resolver:graph').length,
graphLoadingElements: simulator.testSubject('resolver:graph:loading').length,
graphErrorElements: simulator.testSubject('resolver:graph:error').length,
originNode: simulator.processNodeElements({ entityID: entityIDs.origin }).length,
}))
).toYieldEqualTo({
Expand All @@ -147,7 +151,10 @@ describe('Resolver, when analyzing a tree that has two related events for the or
it('should render a related events button', async () => {
await expect(
simulator.map(() => ({
relatedEventButtons: simulator.processNodeRelatedEventButton(entityIDs.origin).length,
relatedEventButtons: simulator.processNodeChildElements(
entityIDs.origin,
'resolver:submenu:button'
).length,
}))
).toYieldEqualTo({
relatedEventButtons: 1,
Expand All @@ -156,33 +163,35 @@ describe('Resolver, when analyzing a tree that has two related events for the or
describe('when the related events button is clicked', () => {
beforeEach(async () => {
const button = await simulator.resolveWrapper(() =>
simulator.processNodeRelatedEventButton(entityIDs.origin)
simulator.processNodeChildElements(entityIDs.origin, 'resolver:submenu:button')
);
if (button) {
button.simulate('click');
}
});
it('should open the submenu and display exactly one option with the correct count', async () => {
await expect(
simulator.map(() => simulator.processNodeSubmenuItems().map((node) => node.text()))
simulator.map(() =>
simulator.testSubject('resolver:map:node-submenu-item').map((node) => node.text())
)
).toYieldEqualTo(['2 registry']);
await expect(
simulator.map(() => simulator.processNodeSubmenuItems().length)
simulator.map(() => simulator.testSubject('resolver:map:node-submenu-item').length)

This comment has been minimized.

Copy link
@oatkiller

oatkiller Aug 13, 2020

Contributor

how dare you sir

).toYieldEqualTo(1);
});
});
describe('and when the related events button is clicked again', () => {
beforeEach(async () => {
const button = await simulator.resolveWrapper(() =>
simulator.processNodeRelatedEventButton(entityIDs.origin)
simulator.processNodeChildElements(entityIDs.origin, 'resolver:submenu:button')
);
if (button) {
button.simulate('click');
}
});
it('should close the submenu', async () => {
await expect(
simulator.map(() => simulator.processNodeSubmenuItems().length)
simulator.map(() => simulator.testSubject('resolver:map:node-submenu-item').length)
).toYieldEqualTo(0);
});
});
Expand Down
Loading

0 comments on commit c34e30e

Please sign in to comment.