Skip to content

Commit c34e30e

Browse files
michaelolo24oatkiller
andauthored
[Security Solution][Resolver] Graph Control Tests and Update Simulator Selectors (#74680)
Co-authored-by: oatkiller <robert.austin@elastic.co>
1 parent 250a0b1 commit c34e30e

File tree

7 files changed

+379
-105
lines changed

7 files changed

+379
-105
lines changed

x-pack/plugins/security_solution/public/resolver/test_utilities/extend_jest.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ declare global {
1717
namespace jest {
1818
interface Matchers<R, T> {
1919
toYieldEqualTo(expectedYield: T extends AsyncIterable<infer E> ? E : never): Promise<R>;
20+
toYieldObjectEqualTo(expectedYield: unknown): Promise<R>;
2021
}
2122
}
2223
}
@@ -57,6 +58,70 @@ expect.extend({
5758
}
5859
}
5960

61+
// Use `pass` as set in the above loop (or initialized to `false`)
62+
// See https://jestjs.io/docs/en/expect#custom-matchers-api and https://jestjs.io/docs/en/expect#thisutils
63+
const message = pass
64+
? () =>
65+
`${this.utils.matcherHint(matcherName, undefined, undefined, options)}\n\n` +
66+
`Expected: not ${this.utils.printExpected(expected)}\n${
67+
this.utils.stringify(expected) !== this.utils.stringify(received[received.length - 1]!)
68+
? `Received: ${this.utils.printReceived(received[received.length - 1])}`
69+
: ''
70+
}`
71+
: () =>
72+
`${this.utils.matcherHint(matcherName, undefined, undefined, options)}\n\nCompared ${
73+
received.length
74+
} yields.\n\n${received
75+
.map(
76+
(next, index) =>
77+
`yield ${index + 1}:\n\n${this.utils.printDiffOrStringify(
78+
expected,
79+
next,
80+
'Expected',
81+
'Received',
82+
this.expand
83+
)}`
84+
)
85+
.join(`\n\n`)}`;
86+
87+
return { message, pass };
88+
},
89+
/**
90+
* A custom matcher that takes an async generator and compares each value it yields to an expected value.
91+
* This uses the same equality logic as `toMatchObject`.
92+
* If any yielded value equals the expected value, the matcher will pass.
93+
* If the generator ends with none of the yielded values matching, it will fail.
94+
*/
95+
async toYieldObjectEqualTo<T>(
96+
this: jest.MatcherContext,
97+
receivedIterable: AsyncIterable<T>,
98+
expected: T
99+
): Promise<{ pass: boolean; message: () => string }> {
100+
// Used in printing out the pass or fail message
101+
const matcherName = 'toSometimesYieldEqualTo';
102+
const options: jest.MatcherHintOptions = {
103+
comment: 'deep equality with any yielded value',
104+
isNot: this.isNot,
105+
promise: this.promise,
106+
};
107+
// The last value received: Used in printing the message
108+
const received: T[] = [];
109+
110+
// Set to true if the test passes.
111+
let pass: boolean = false;
112+
113+
// Async iterate over the iterable
114+
for await (const next of receivedIterable) {
115+
// keep track of all received values. Used in pass and fail messages
116+
received.push(next);
117+
// Use deep equals to compare the value to the expected value
118+
if ((this.equals(next, expected), [this.utils.iterableEquality, this.utils.subsetEquality])) {
119+
// If the value is equal, break
120+
pass = true;
121+
break;
122+
}
123+
}
124+
60125
// Use `pass` as set in the above loop (or initialized to `false`)
61126
// See https://jestjs.io/docs/en/expect#custom-matchers-api and https://jestjs.io/docs/en/expect#thisutils
62127
const message = pass

x-pack/plugins/security_solution/public/resolver/test_utilities/simulator/index.tsx

Lines changed: 33 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,9 @@ import { spyMiddlewareFactory } from '../spy_middleware_factory';
1414
import { resolverMiddlewareFactory } from '../../store/middleware';
1515
import { resolverReducer } from '../../store/reducer';
1616
import { MockResolver } from './mock_resolver';
17-
import { ResolverState, DataAccessLayer, SpyMiddleware } from '../../types';
17+
import { ResolverState, DataAccessLayer, SpyMiddleware, SideEffectSimulator } from '../../types';
1818
import { ResolverAction } from '../../store/actions';
19+
import { sideEffectSimulatorFactory } from '../../view/side_effect_simulator_factory';
1920

2021
/**
2122
* Test a Resolver instance using jest, enzyme, and a mock data layer.
@@ -43,6 +44,11 @@ export class Simulator {
4344
* This is used by `debugActions`.
4445
*/
4546
private readonly spyMiddleware: SpyMiddleware;
47+
/**
48+
* Simulator which allows you to explicitly simulate resize events and trigger animation frames
49+
*/
50+
private readonly sideEffectSimulator: SideEffectSimulator;
51+
4652
constructor({
4753
dataAccessLayer,
4854
resolverComponentInstanceID,
@@ -87,11 +93,14 @@ export class Simulator {
8793
// Used for `KibanaContextProvider`
8894
const coreStart: CoreStart = coreMock.createStart();
8995

96+
this.sideEffectSimulator = sideEffectSimulatorFactory();
97+
9098
// Render Resolver via the `MockResolver` component, using `enzyme`.
9199
this.wrapper = mount(
92100
<MockResolver
93101
resolverComponentInstanceID={this.resolverComponentInstanceID}
94102
history={this.history}
103+
sideEffectSimulator={this.sideEffectSimulator}
95104
store={this.store}
96105
coreStart={coreStart}
97106
databaseDocumentID={databaseDocumentID}
@@ -149,6 +158,18 @@ export class Simulator {
149158
return this.domNodes(processNodeElementSelector(options));
150159
}
151160

161+
/**
162+
* Return an Enzyme ReactWrapper for any child elements of a specific processNodeElement
163+
*
164+
* @param entityID The entity ID of the proocess node to select in
165+
* @param selector The selector for the child element of the process node
166+
*/
167+
public processNodeChildElements(entityID: string, selector: string): ReactWrapper {
168+
return this.domNodes(
169+
`${processNodeElementSelector({ entityID })} [data-test-subj="${selector}"]`
170+
);
171+
}
172+
152173
/**
153174
* Return the node element with the given `entityID`.
154175
*/
@@ -174,21 +195,11 @@ export class Simulator {
174195
}
175196

176197
/**
177-
* Return an Enzyme ReactWrapper that includes the Related Events host button for a given process node
178-
*
179-
* @param entityID The entity ID of the proocess node to select in
198+
* This manually runs the animation frames tied to a configurable timestamp in the future
180199
*/
181-
public processNodeRelatedEventButton(entityID: string): ReactWrapper {
182-
return this.domNodes(
183-
`${processNodeElementSelector({ entityID })} [data-test-subj="resolver:submenu:button"]`
184-
);
185-
}
186-
187-
/**
188-
* The items in the submenu that is opened by expanding a node in the map.
189-
*/
190-
public processNodeSubmenuItems(): ReactWrapper {
191-
return this.domNodes('[data-test-subj="resolver:map:node-submenu-item"]');
200+
public runAnimationFramesTimeFromNow(time: number = 0) {
201+
this.sideEffectSimulator.controls.time = time;
202+
this.sideEffectSimulator.controls.provideAnimationFrame();
192203
}
193204

194205
/**
@@ -202,59 +213,17 @@ export class Simulator {
202213
}
203214

204215
/**
205-
* The element that shows when Resolver is waiting for the graph data.
206-
*/
207-
public graphLoadingElement(): ReactWrapper {
208-
return this.domNodes('[data-test-subj="resolver:graph:loading"]');
209-
}
210-
211-
/**
212-
* The element that shows if Resolver couldn't draw the graph.
213-
*/
214-
public graphErrorElement(): ReactWrapper {
215-
return this.domNodes('[data-test-subj="resolver:graph:error"]');
216-
}
217-
218-
/**
219-
* The element where nodes get drawn.
220-
*/
221-
public graphElement(): ReactWrapper {
222-
return this.domNodes('[data-test-subj="resolver:graph"]');
223-
}
224-
225-
/**
226-
* The titles of the links that select a node in the node list view.
227-
*/
228-
public nodeListNodeLinkText(): ReactWrapper {
229-
return this.domNodes('[data-test-subj="resolver:node-list:node-link:title"]');
230-
}
231-
232-
/**
233-
* The icons in the links that select a node in the node list view.
234-
*/
235-
public nodeListNodeLinkIcons(): ReactWrapper {
236-
return this.domNodes('[data-test-subj="resolver:node-list:node-link:icon"]');
237-
}
238-
239-
/**
240-
* Link rendered in the breadcrumbs of the node detail view. Takes the user to the node list.
241-
*/
242-
public nodeDetailBreadcrumbNodeListLink(): ReactWrapper {
243-
return this.domNodes('[data-test-subj="resolver:node-detail:breadcrumbs:node-list-link"]');
244-
}
245-
246-
/**
247-
* The title element for the node detail view.
216+
* Given a 'data-test-subj' value, it will resolve the react wrapper or undefined if not found
248217
*/
249-
public nodeDetailViewTitle(): ReactWrapper {
250-
return this.domNodes('[data-test-subj="resolver:node-detail:title"]');
218+
public async resolve(selector: string): Promise<ReactWrapper | undefined> {
219+
return this.resolveWrapper(() => this.domNodes(`[data-test-subj="${selector}"]`));
251220
}
252221

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

260229
/**
@@ -297,7 +266,7 @@ export class Simulator {
297266
public async resolveWrapper(
298267
wrapperFactory: () => ReactWrapper,
299268
predicate: (wrapper: ReactWrapper) => boolean = (wrapper) => wrapper.length > 0
300-
): Promise<ReactWrapper | void> {
269+
): Promise<ReactWrapper | undefined> {
301270
for await (const wrapper of this.map(wrapperFactory)) {
302271
if (predicate(wrapper)) {
303272
return wrapper;

x-pack/plugins/security_solution/public/resolver/test_utilities/simulator/mock_resolver.tsx

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

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

9-
import React, { useMemo, useEffect, useState, useCallback } from 'react';
9+
import React, { useEffect, useState, useCallback } from 'react';
1010
import { Router } from 'react-router-dom';
1111
import { I18nProvider } from '@kbn/i18n/react';
1212
import { Provider } from 'react-redux';
@@ -17,7 +17,6 @@ import { ResolverState, SideEffectSimulator, ResolverProps } from '../../types';
1717
import { ResolverAction } from '../../store/actions';
1818
import { ResolverWithoutProviders } from '../../view/resolver_without_providers';
1919
import { SideEffectContext } from '../../view/side_effect_context';
20-
import { sideEffectSimulatorFactory } from '../../view/side_effect_simulator_factory';
2120

2221
type MockResolverProps = {
2322
/**
@@ -38,6 +37,10 @@ type MockResolverProps = {
3837
history: React.ComponentProps<typeof Router>['history'];
3938
/** Pass a resolver store. See `storeFactory` and `mockDataAccessLayer` */
4039
store: Store<ResolverState, ResolverAction>;
40+
/**
41+
* Pass the side effect simulator which handles animations and resizing. See `sideEffectSimulatorFactory`
42+
*/
43+
sideEffectSimulator: SideEffectSimulator;
4144
/**
4245
* 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.)
4346
*/
@@ -66,8 +69,6 @@ export const MockResolver = React.memo((props: MockResolverProps) => {
6669
setResolverElement(element);
6770
}, []);
6871

69-
const simulator: SideEffectSimulator = useMemo(() => sideEffectSimulatorFactory(), []);
70-
7172
// Resize the Resolver element to match the passed in props. Resolver is size dependent.
7273
useEffect(() => {
7374
if (resolverElement) {
@@ -84,15 +85,15 @@ export const MockResolver = React.memo((props: MockResolverProps) => {
8485
return this;
8586
},
8687
};
87-
simulator.controls.simulateElementResize(resolverElement, size);
88+
props.sideEffectSimulator.controls.simulateElementResize(resolverElement, size);
8889
}
89-
}, [props.rasterWidth, props.rasterHeight, simulator.controls, resolverElement]);
90+
}, [props.rasterWidth, props.rasterHeight, props.sideEffectSimulator.controls, resolverElement]);
9091

9192
return (
9293
<I18nProvider>
9394
<Router history={props.history}>
9495
<KibanaContextProvider services={props.coreStart}>
95-
<SideEffectContext.Provider value={simulator.mock}>
96+
<SideEffectContext.Provider value={props.sideEffectSimulator.mock}>
9697
<Provider store={props.store}>
9798
<ResolverWithoutProviders
9899
ref={resolverRef}

x-pack/plugins/security_solution/public/resolver/view/clickthrough.test.tsx

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,9 @@ describe('Resolver, when analyzing a tree that has no ancestors and 2 children',
4242
* 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.
4343
*/
4444
simulator.map(() => ({
45-
graphElements: simulator.graphElement().length,
46-
graphLoadingElements: simulator.graphLoadingElement().length,
47-
graphErrorElements: simulator.graphErrorElement().length,
45+
graphElements: simulator.testSubject('resolver:graph').length,
46+
graphLoadingElements: simulator.testSubject('resolver:graph:loading').length,
47+
graphErrorElements: simulator.testSubject('resolver:graph:error').length,
4848
}))
4949
).toYieldEqualTo({
5050
// it should have 1 graph element, an no error or loading elements.
@@ -72,8 +72,12 @@ describe('Resolver, when analyzing a tree that has no ancestors and 2 children',
7272
});
7373

7474
it(`should show links to the 3 nodes (with icons) in the node list.`, async () => {
75-
await expect(simulator.map(() => simulator.nodeListNodeLinkText().length)).toYieldEqualTo(3);
76-
await expect(simulator.map(() => simulator.nodeListNodeLinkIcons().length)).toYieldEqualTo(3);
75+
await expect(
76+
simulator.map(() => simulator.testSubject('resolver:node-list:node-link:title').length)
77+
).toYieldEqualTo(3);
78+
await expect(
79+
simulator.map(() => simulator.testSubject('resolver:node-list:node-link:title').length)
80+
).toYieldEqualTo(3);
7781
});
7882

7983
describe("when the second child node's first button has been clicked", () => {
@@ -131,9 +135,9 @@ describe('Resolver, when analyzing a tree that has two related events for the or
131135
beforeEach(async () => {
132136
await expect(
133137
simulator.map(() => ({
134-
graphElements: simulator.graphElement().length,
135-
graphLoadingElements: simulator.graphLoadingElement().length,
136-
graphErrorElements: simulator.graphErrorElement().length,
138+
graphElements: simulator.testSubject('resolver:graph').length,
139+
graphLoadingElements: simulator.testSubject('resolver:graph:loading').length,
140+
graphErrorElements: simulator.testSubject('resolver:graph:error').length,
137141
originNode: simulator.processNodeElements({ entityID: entityIDs.origin }).length,
138142
}))
139143
).toYieldEqualTo({
@@ -147,7 +151,10 @@ describe('Resolver, when analyzing a tree that has two related events for the or
147151
it('should render a related events button', async () => {
148152
await expect(
149153
simulator.map(() => ({
150-
relatedEventButtons: simulator.processNodeRelatedEventButton(entityIDs.origin).length,
154+
relatedEventButtons: simulator.processNodeChildElements(
155+
entityIDs.origin,
156+
'resolver:submenu:button'
157+
).length,
151158
}))
152159
).toYieldEqualTo({
153160
relatedEventButtons: 1,
@@ -156,33 +163,35 @@ describe('Resolver, when analyzing a tree that has two related events for the or
156163
describe('when the related events button is clicked', () => {
157164
beforeEach(async () => {
158165
const button = await simulator.resolveWrapper(() =>
159-
simulator.processNodeRelatedEventButton(entityIDs.origin)
166+
simulator.processNodeChildElements(entityIDs.origin, 'resolver:submenu:button')
160167
);
161168
if (button) {
162169
button.simulate('click');
163170
}
164171
});
165172
it('should open the submenu and display exactly one option with the correct count', async () => {
166173
await expect(
167-
simulator.map(() => simulator.processNodeSubmenuItems().map((node) => node.text()))
174+
simulator.map(() =>
175+
simulator.testSubject('resolver:map:node-submenu-item').map((node) => node.text())
176+
)
168177
).toYieldEqualTo(['2 registry']);
169178
await expect(
170-
simulator.map(() => simulator.processNodeSubmenuItems().length)
179+
simulator.map(() => simulator.testSubject('resolver:map:node-submenu-item').length)
171180
).toYieldEqualTo(1);
172181
});
173182
});
174183
describe('and when the related events button is clicked again', () => {
175184
beforeEach(async () => {
176185
const button = await simulator.resolveWrapper(() =>
177-
simulator.processNodeRelatedEventButton(entityIDs.origin)
186+
simulator.processNodeChildElements(entityIDs.origin, 'resolver:submenu:button')
178187
);
179188
if (button) {
180189
button.simulate('click');
181190
}
182191
});
183192
it('should close the submenu', async () => {
184193
await expect(
185-
simulator.map(() => simulator.processNodeSubmenuItems().length)
194+
simulator.map(() => simulator.testSubject('resolver:map:node-submenu-item').length)
186195
).toYieldEqualTo(0);
187196
});
188197
});

0 commit comments

Comments
 (0)