diff --git a/x-pack/plugins/security_solution/public/resolver/test_utilities/extend_jest.ts b/x-pack/plugins/security_solution/public/resolver/test_utilities/extend_jest.ts index 9fc7af38beb42..df8f32d15a7ab 100644 --- a/x-pack/plugins/security_solution/public/resolver/test_utilities/extend_jest.ts +++ b/x-pack/plugins/security_solution/public/resolver/test_utilities/extend_jest.ts @@ -17,6 +17,7 @@ declare global { namespace jest { interface Matchers { toYieldEqualTo(expectedYield: T extends AsyncIterable ? E : never): Promise; + toYieldObjectEqualTo(expectedYield: unknown): Promise; } } } @@ -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( + this: jest.MatcherContext, + receivedIterable: AsyncIterable, + 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 diff --git a/x-pack/plugins/security_solution/public/resolver/test_utilities/simulator/index.tsx b/x-pack/plugins/security_solution/public/resolver/test_utilities/simulator/index.tsx index cae6a18576ebd..355b53e374092 100644 --- a/x-pack/plugins/security_solution/public/resolver/test_utilities/simulator/index.tsx +++ b/x-pack/plugins/security_solution/public/resolver/test_utilities/simulator/index.tsx @@ -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. @@ -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, @@ -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( { + 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}"]`); } /** @@ -297,7 +266,7 @@ export class Simulator { public async resolveWrapper( wrapperFactory: () => ReactWrapper, predicate: (wrapper: ReactWrapper) => boolean = (wrapper) => wrapper.length > 0 - ): Promise { + ): Promise { for await (const wrapper of this.map(wrapperFactory)) { if (predicate(wrapper)) { return wrapper; diff --git a/x-pack/plugins/security_solution/public/resolver/test_utilities/simulator/mock_resolver.tsx b/x-pack/plugins/security_solution/public/resolver/test_utilities/simulator/mock_resolver.tsx index 7de7cf48e6039..5d5a414761dbf 100644 --- a/x-pack/plugins/security_solution/public/resolver/test_utilities/simulator/mock_resolver.tsx +++ b/x-pack/plugins/security_solution/public/resolver/test_utilities/simulator/mock_resolver.tsx @@ -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'; @@ -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 = { /** @@ -38,6 +37,10 @@ type MockResolverProps = { history: React.ComponentProps['history']; /** Pass a resolver store. See `storeFactory` and `mockDataAccessLayer` */ store: Store; + /** + * 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.) */ @@ -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) { @@ -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 ( - + ({ - 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. @@ -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", () => { @@ -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({ @@ -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, @@ -156,7 +163,7 @@ 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'); @@ -164,17 +171,19 @@ describe('Resolver, when analyzing a tree that has two related events for the or }); 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) ).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'); @@ -182,7 +191,7 @@ describe('Resolver, when analyzing a tree that has two related events for the or }); 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); }); }); diff --git a/x-pack/plugins/security_solution/public/resolver/view/graph_controls.test.tsx b/x-pack/plugins/security_solution/public/resolver/view/graph_controls.test.tsx new file mode 100644 index 0000000000000..6497cc2971980 --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/view/graph_controls.test.tsx @@ -0,0 +1,221 @@ +/* + * 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 { Simulator } from '../test_utilities/simulator'; +import { noAncestorsTwoChildren } from '../data_access_layer/mocks/no_ancestors_two_children'; +import { nudgeAnimationDuration } from '../store/camera/scaling_constants'; +import '../test_utilities/extend_jest'; + +describe('graph controls: when relsover is loaded with an origin node', () => { + let simulator: Simulator; + let originEntityID: string; + let originNodeStyle: () => AsyncIterable; + const resolverComponentInstanceID = 'graph-controls-test'; + + const originalPositionStyle: Readonly<{ left: string; top: string }> = { + left: '746.93132px', + top: '535.5792px', + }; + const originalSizeStyle: Readonly<{ width: string; height: string }> = { + width: '360px', + height: '120px', + }; + + beforeEach(async () => { + const { + metadata: { databaseDocumentID, entityIDs }, + dataAccessLayer, + } = noAncestorsTwoChildren(); + + simulator = new Simulator({ + dataAccessLayer, + databaseDocumentID, + resolverComponentInstanceID, + }); + originEntityID = entityIDs.origin; + + originNodeStyle = () => + simulator.map(() => { + const wrapper = simulator.processNodeElements({ entityID: originEntityID }); + // `getDOMNode` can only be called on a wrapper of a single node: https://enzymejs.github.io/enzyme/docs/api/ReactWrapper/getDOMNode.html + if (wrapper.length === 1) { + return wrapper.getDOMNode().style; + } + return null; + }); + }); + + it('should display all cardinal panning buttons and the center button', async () => { + await expect( + simulator.map(() => ({ + westPanButton: simulator.testSubject('resolver:graph-controls:west-button').length, + southPanButton: simulator.testSubject('resolver:graph-controls:south-button').length, + eastPanButton: simulator.testSubject('resolver:graph-controls:east-button').length, + northPanButton: simulator.testSubject('resolver:graph-controls:north-button').length, + centerButton: simulator.testSubject('resolver:graph-controls:center-button').length, + })) + ).toYieldEqualTo({ + westPanButton: 1, + southPanButton: 1, + eastPanButton: 1, + northPanButton: 1, + centerButton: 1, + }); + }); + + it('should display the zoom buttons and slider', async () => { + await expect( + simulator.map(() => ({ + zoomInButton: simulator.testSubject('resolver:graph-controls:zoom-in').length, + zoomOutButton: simulator.testSubject('resolver:graph-controls:zoom-out').length, + zoomSlider: simulator.testSubject('resolver:graph-controls:zoom-slider').length, + })) + ).toYieldEqualTo({ + zoomInButton: 1, + zoomOutButton: 1, + zoomSlider: 1, + }); + }); + + it("should show the origin node in it's original position", async () => { + await expect(originNodeStyle()).toYieldObjectEqualTo(originalPositionStyle); + }); + + describe('when the user clicks the west panning button', () => { + beforeEach(async () => { + (await simulator.resolve('resolver:graph-controls:west-button'))!.simulate('click'); + simulator.runAnimationFramesTimeFromNow(nudgeAnimationDuration); + }); + + it('should show the origin node further left on the screen', async () => { + await expect(originNodeStyle()).toYieldObjectEqualTo({ + left: '796.93132px', + top: '535.5792px', + }); + }); + }); + + describe('when the user clicks the south panning button', () => { + beforeEach(async () => { + (await simulator.resolve('resolver:graph-controls:south-button'))!.simulate('click'); + simulator.runAnimationFramesTimeFromNow(nudgeAnimationDuration); + }); + + it('should show the origin node lower on the screen', async () => { + await expect(originNodeStyle()).toYieldObjectEqualTo({ + left: '746.93132px', + top: '485.5792px', + }); + }); + }); + + describe('when the user clicks the east panning button', () => { + beforeEach(async () => { + (await simulator.resolve('resolver:graph-controls:east-button'))!.simulate('click'); + simulator.runAnimationFramesTimeFromNow(nudgeAnimationDuration); + }); + + it('should show the origin node further right on the screen', async () => { + await expect(originNodeStyle()).toYieldObjectEqualTo({ + left: '696.93132px', + top: '535.5792px', + }); + }); + }); + + describe('when the user clicks the north panning button', () => { + beforeEach(async () => { + (await simulator.resolve('resolver:graph-controls:north-button'))!.simulate('click'); + simulator.runAnimationFramesTimeFromNow(nudgeAnimationDuration); + }); + + it('should show the origin node higher on the screen', async () => { + await expect(originNodeStyle()).toYieldObjectEqualTo({ + left: '746.93132px', + top: '585.5792px', + }); + }); + }); + + describe('when the user clicks the center panning button', () => { + beforeEach(async () => { + (await simulator.resolve('resolver:graph-controls:north-button'))!.simulate('click'); + simulator.runAnimationFramesTimeFromNow(nudgeAnimationDuration); + (await simulator.resolve('resolver:graph-controls:center-button'))!.simulate('click'); + simulator.runAnimationFramesTimeFromNow(nudgeAnimationDuration); + }); + + it("should return the origin node to it's original position", async () => { + await expect(originNodeStyle()).toYieldObjectEqualTo(originalPositionStyle); + }); + }); + + it('should show the origin node as larger on the screen', async () => { + await expect(originNodeStyle()).toYieldObjectEqualTo(originalSizeStyle); + }); + + describe('when the zoom in button is clicked', () => { + beforeEach(async () => { + (await simulator.resolve('resolver:graph-controls:zoom-in'))!.simulate('click'); + simulator.runAnimationFramesTimeFromNow(nudgeAnimationDuration); + }); + + it('should show the origin node as larger on the screen', async () => { + await expect(originNodeStyle()).toYieldObjectEqualTo({ + width: '427.7538290724795px', + height: '142.5846096908265px', + }); + }); + }); + + describe('when the zoom out button is clicked', () => { + beforeEach(async () => { + (await simulator.resolve('resolver:graph-controls:zoom-out'))!.simulate('click'); + simulator.runAnimationFramesTimeFromNow(nudgeAnimationDuration); + }); + + it('should show the origin node as smaller on the screen', async () => { + await expect(originNodeStyle()).toYieldObjectEqualTo({ + width: '303.0461709275204px', + height: '101.01539030917347px', + }); + }); + }); + + describe('when the slider is moved upwards', () => { + beforeEach(async () => { + await expect(originNodeStyle()).toYieldObjectEqualTo(originalSizeStyle); + + (await simulator.resolve('resolver:graph-controls:zoom-slider'))!.simulate('change', { + target: { value: 0.8 }, + }); + simulator.runAnimationFramesTimeFromNow(nudgeAnimationDuration); + }); + + it('should show the origin node as large on the screen', async () => { + await expect(originNodeStyle()).toYieldObjectEqualTo({ + width: '525.6000000000001px', + height: '175.20000000000005px', + }); + }); + }); + + describe('when the slider is moved downwards', () => { + beforeEach(async () => { + (await simulator.resolve('resolver:graph-controls:zoom-slider'))!.simulate('change', { + target: { value: 0.2 }, + }); + simulator.runAnimationFramesTimeFromNow(nudgeAnimationDuration); + }); + + it('should show the origin node as smaller on the screen', async () => { + await expect(originNodeStyle()).toYieldObjectEqualTo({ + width: '201.60000000000002px', + height: '67.2px', + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/resolver/view/graph_controls.tsx b/x-pack/plugins/security_solution/public/resolver/view/graph_controls.tsx index c2a7bbaacbf1d..610deef07775b 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/graph_controls.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/graph_controls.tsx @@ -125,12 +125,13 @@ const GraphControlsComponent = React.memo( className={className} graphControlsBackground={colorMap.graphControlsBackground} graphControlsIconColor={colorMap.graphControls} + data-test-subj="resolver:graph-controls" >
- - diff --git a/x-pack/plugins/security_solution/public/resolver/view/panel.test.tsx b/x-pack/plugins/security_solution/public/resolver/view/panel.test.tsx index 4d391a6c9ce59..21b5a30ee9890 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panel.test.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panel.test.tsx @@ -72,8 +72,8 @@ describe(`Resolver: when analyzing a tree with no ancestors and two children, an it('should show the node details for the origin', async () => { await expect( simulator().map(() => { - const titleWrapper = simulator().nodeDetailViewTitle(); - const titleIconWrapper = simulator().nodeDetailViewTitleIcon(); + const titleWrapper = simulator().testSubject('resolver:node-detail:title'); + const titleIconWrapper = simulator().testSubject('resolver:node-detail:title-icon'); return { title: titleWrapper.exists() ? titleWrapper.text() : null, titleIcon: titleIconWrapper.exists() ? titleIconWrapper.text() : null, @@ -122,17 +122,17 @@ describe(`Resolver: when analyzing a tree with no ancestors and two children, an }); it('should have 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:icon').length) + ).toYieldEqualTo(3); }); describe('when there is an item in the node list and its text has been clicked', () => { beforeEach(async () => { - const nodeLinks = await simulator().resolveWrapper(() => simulator().nodeListNodeLinkText()); + const nodeLinks = await simulator().resolve('resolver:node-list:node-link:title'); expect(nodeLinks).toBeTruthy(); if (nodeLinks) { nodeLinks.first().simulate('click'); @@ -158,8 +158,8 @@ describe(`Resolver: when analyzing a tree with no ancestors and two children, an }); describe('and when the node list link has been clicked', () => { beforeEach(async () => { - const nodeListLink = await simulator().resolveWrapper(() => - simulator().nodeDetailBreadcrumbNodeListLink() + const nodeListLink = await simulator().resolve( + 'resolver:node-detail:breadcrumbs:node-list-link' ); if (nodeListLink) { nodeListLink.simulate('click'); @@ -169,7 +169,7 @@ describe(`Resolver: when analyzing a tree with no ancestors and two children, an await expect( simulator().map(() => { return simulator() - .nodeListNodeLinkText() + .testSubject('resolver:node-list:node-link:title') .map((node) => node.text()); }) ).toYieldEqualTo(['c', 'd', 'e']);