From 763d5a63825efb1b87234874012bb9f24501e608 Mon Sep 17 00:00:00 2001 From: Michael Olorunnisola Date: Tue, 5 Jan 2021 13:27:12 -0500 Subject: [PATCH] [Security Solution][Resolver] - Maintain active node (#86682) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../public/resolver/store/actions.ts | 53 +++---- .../resolver/store/camera/animation.test.ts | 5 +- .../store/camera/scaling_constants.ts | 5 + .../store/middleware/resolver_tree_fetcher.ts | 21 --- .../public/resolver/store/reducer.ts | 26 ++-- .../public/resolver/view/panels/node_list.tsx | 2 +- .../resolver/view/process_event_dot.tsx | 18 ++- .../view/resolver_without_providers.tsx | 6 + .../public/resolver/view/submenu.tsx | 2 +- .../public/resolver/view/use_camera.test.tsx | 5 +- .../view/use_sync_selected_node.test.tsx | 137 ++++++++++++++++++ .../resolver/view/use_sync_selected_node.ts | 59 ++++++++ .../test_suites/resolver/index.ts | 10 +- 13 files changed, 268 insertions(+), 81 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/resolver/view/use_sync_selected_node.test.tsx create mode 100644 x-pack/plugins/security_solution/public/resolver/view/use_sync_selected_node.ts diff --git a/x-pack/plugins/security_solution/public/resolver/store/actions.ts b/x-pack/plugins/security_solution/public/resolver/store/actions.ts index f5ad9883ca851..e593e95ba2e1f 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/actions.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/actions.ts @@ -8,32 +8,6 @@ import { CameraAction } from './camera'; import { DataAction } from './data/action'; -/** - * When the user wants to bring a node front-and-center on the map. - * @deprecated Nodes are brought into view upon selection instead. See `appReceivedNewExternalProperties` - */ -interface UserBroughtNodeIntoView { - /** - * @deprecated Nodes are brought into view upon selection instead. See `appReceivedNewExternalProperties` - */ - readonly type: 'userBroughtNodeIntoView'; - /** - * @deprecated Nodes are brought into view upon selection instead. See `appReceivedNewExternalProperties` - */ - readonly payload: { - /** - * Used to identify the node that should be brought into view. - * @deprecated Nodes are brought into view upon selection instead. See `appReceivedNewExternalProperties` - */ - readonly nodeID: string; - /** - * The time (since epoch in milliseconds) when the action was dispatched. - * @deprecated Nodes are brought into view upon selection instead. See `appReceivedNewExternalProperties` - */ - readonly time: number; - }; -} - /** * The action dispatched when the app requests related event data for one * subject (whose entity_id should be included as `payload`) @@ -53,8 +27,16 @@ interface UserRequestedRelatedEventData { interface UserFocusedOnResolverNode { readonly type: 'userFocusedOnResolverNode'; - /** focused nodeID */ - readonly payload: string; + readonly payload: { + /** + * Used to identify the node that should be brought into view. + */ + readonly nodeID: string; + /** + * The time (since epoch in milliseconds) when the action was dispatched. + */ + readonly time: number; + }; } /** @@ -65,10 +47,16 @@ interface UserFocusedOnResolverNode { */ interface UserSelectedResolverNode { readonly type: 'userSelectedResolverNode'; - /** - * The nodeID (aka entity_id) that was select. - */ - readonly payload: string; + readonly payload: { + /** + * Used to identify the node that should be brought into view. + */ + readonly nodeID: string; + /** + * The time (since epoch in milliseconds) when the action was dispatched. + */ + readonly time: number; + }; } /** @@ -113,7 +101,6 @@ export type ResolverAction = | CameraAction | DataAction | AppReceivedNewExternalProperties - | UserBroughtNodeIntoView | UserFocusedOnResolverNode | UserSelectedResolverNode | UserRequestedRelatedEventData; diff --git a/x-pack/plugins/security_solution/public/resolver/store/camera/animation.test.ts b/x-pack/plugins/security_solution/public/resolver/store/camera/animation.test.ts index 26f86453b2c0e..69d87e6ccd351 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/camera/animation.test.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/camera/animation.test.ts @@ -12,6 +12,7 @@ import * as selectors from './selectors'; import { animatePanning } from './methods'; import { lerp } from '../../lib/math'; import { ResolverAction } from '../actions'; +import { panAnimationDuration } from './scaling_constants'; type TestAction = | ResolverAction @@ -60,7 +61,7 @@ describe('when the camera is created', () => { }); describe('When attempting to pan to current position and scale', () => { - const duration = 1000; + const duration = panAnimationDuration; const startTime = 0; beforeEach(() => { const action: TestAction = { @@ -102,7 +103,7 @@ describe('when the camera is created', () => { }); describe('when animation begins', () => { - const duration = 1000; + const duration = panAnimationDuration; let targetTranslation: Vector2; const startTime = 0; beforeEach(() => { diff --git a/x-pack/plugins/security_solution/public/resolver/store/camera/scaling_constants.ts b/x-pack/plugins/security_solution/public/resolver/store/camera/scaling_constants.ts index 11e8495c491b0..d2ce687a7e4b5 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/camera/scaling_constants.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/camera/scaling_constants.ts @@ -29,3 +29,8 @@ export const unitsPerNudge = 50; * The duration a nudge animation lasts. */ export const nudgeAnimationDuration = 300; + +/** + * The duration a panning animation lasts + */ +export const panAnimationDuration = 1000; diff --git a/x-pack/plugins/security_solution/public/resolver/store/middleware/resolver_tree_fetcher.ts b/x-pack/plugins/security_solution/public/resolver/store/middleware/resolver_tree_fetcher.ts index e118ebe62a65c..ff5484ff20214 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/middleware/resolver_tree_fetcher.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/middleware/resolver_tree_fetcher.ts @@ -35,7 +35,6 @@ export function ResolverTreeFetcher( return async () => { const state = api.getState(); const databaseParameters = selectors.treeParametersToFetch(state); - const currentPanelParameters = selectors.panelViewAndParameters(state); if (selectors.treeRequestParametersToAbort(state) && lastRequestAbortController) { lastRequestAbortController.abort(); @@ -93,26 +92,6 @@ export function ResolverTreeFetcher( parameters: databaseParameters, }, }); - - /* - * Necessary to handle refresh states where another node besides the origin was selected - * If the user has selected another node, but is back to viewing the nodeList, nodeID won't be set in the url - * So after a refresh the focused node will be the originID. - * This is okay for now, but can be updated if we decide to track selectedNode in panelParameters. - */ - // no nodeID on the 'nodes' (nodeList) view. - if (currentPanelParameters && currentPanelParameters.panelView !== 'nodes') { - const { nodeID } = currentPanelParameters.panelParameters; - const urlHasDefinedNode = result.find((node) => node.id === nodeID); - api.dispatch({ - type: 'userBroughtNodeIntoView', - payload: { - // In the event the origin is the url selectedNode, the animation has logic to prevent an unnecessary transition taking place - nodeID: urlHasDefinedNode ? nodeID : entityIDToFetch, - time: Date.now(), - }, - }); - } } catch (error) { // https://developer.mozilla.org/en-US/docs/Web/API/DOMException#exception-AbortError if (error instanceof DOMException && error.name === 'AbortError') { diff --git a/x-pack/plugins/security_solution/public/resolver/store/reducer.ts b/x-pack/plugins/security_solution/public/resolver/store/reducer.ts index 805ea817f4e18..de15d4be7ecf6 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/reducer.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/reducer.ts @@ -12,6 +12,7 @@ import { cameraReducer } from './camera/reducer'; import { dataReducer } from './data/reducer'; import { ResolverAction } from './actions'; import { ResolverState, ResolverUIState } from '../types'; +import { panAnimationDuration } from './camera/scaling_constants'; import { nodePosition } from '../models/indexed_process_tree/isometric_taxi_layout'; const uiReducer: Reducer = ( @@ -31,22 +32,14 @@ const uiReducer: Reducer = ( } else if (action.type === 'userFocusedOnResolverNode') { const next: ResolverUIState = { ...state, - ariaActiveDescendant: action.payload, + ariaActiveDescendant: action.payload.nodeID, }; return next; } else if (action.type === 'userSelectedResolverNode') { const next: ResolverUIState = { ...state, - selectedNode: action.payload, - }; - return next; - } else if (action.type === 'userBroughtNodeIntoView') { - const { nodeID } = action.payload; - const next: ResolverUIState = { - ...state, - // Select the node. NB: Animation is handled in the reducer as well. - ariaActiveDescendant: nodeID, - selectedNode: nodeID, + selectedNode: action.payload.nodeID, + ariaActiveDescendant: action.payload.nodeID, }; return next; } else if (action.type === 'appReceivedNewExternalProperties') { @@ -66,16 +59,19 @@ const concernReducers = combineReducers({ data: dataReducer, ui: uiReducer, }); -const animationDuration = 1000; - export const resolverReducer: Reducer = (state, action) => { const nextState = concernReducers(state, action); - if (action.type === 'userBroughtNodeIntoView') { + if (action.type === 'userSelectedResolverNode' || action.type === 'userFocusedOnResolverNode') { const position = nodePosition(layout(nextState), action.payload.nodeID); if (position) { const withAnimation: ResolverState = { ...nextState, - camera: animatePanning(nextState.camera, action.payload.time, position, animationDuration), + camera: animatePanning( + nextState.camera, + action.payload.time, + position, + panAnimationDuration + ), }; return withAnimation; } else { diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/node_list.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/node_list.tsx index 53d1cede56188..f3a85635eb9a8 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/node_list.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/node_list.tsx @@ -152,7 +152,7 @@ function NodeDetailLink({ name, nodeID }: { name?: string; nodeID: string }) { (mouseEvent: React.MouseEvent) => { linkProps.onClick(mouseEvent); dispatch({ - type: 'userBroughtNodeIntoView', + type: 'userSelectedResolverNode', payload: { nodeID, time: timestamp(), diff --git a/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx b/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx index 11cffeee94f74..9ddb35bb94f2d 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useCallback, useMemo } from 'react'; +import React, { useCallback, useMemo, useContext } from 'react'; import styled from 'styled-components'; import { htmlIdGenerator, EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { useSelector } from 'react-redux'; @@ -16,6 +16,7 @@ import { applyMatrix3 } from '../models/vector2'; import { Vector2, Matrix3, ResolverState } from '../types'; import { ResolverNode } from '../../../common/endpoint/types'; import { useResolverDispatch } from './use_resolver_dispatch'; +import { SideEffectContext } from './side_effect_context'; import * as nodeModel from '../../../common/endpoint/models/node'; import * as selectors from '../store/selectors'; import { fontSize } from './font_size'; @@ -156,6 +157,7 @@ const UnstyledProcessEventDot = React.memo( const htmlIDPrefix = `resolver:${resolverComponentInstanceID}`; const symbolIDs = useSymbolIDs(); + const { timestamp } = useContext(SideEffectContext); /** * Convert the position, which is in 'world' coordinates, to screen coordinates. @@ -289,9 +291,12 @@ const UnstyledProcessEventDot = React.memo( const handleFocus = useCallback(() => { dispatch({ type: 'userFocusedOnResolverNode', - payload: nodeID, + payload: { + nodeID, + time: timestamp(), + }, }); - }, [dispatch, nodeID]); + }, [dispatch, nodeID, timestamp]); const handleClick = useCallback( (clickEvent) => { @@ -307,12 +312,15 @@ const UnstyledProcessEventDot = React.memo( } else { dispatch({ type: 'userSelectedResolverNode', - payload: nodeID, + payload: { + nodeID, + time: timestamp(), + }, }); processDetailNavProps.onClick(clickEvent); } }, - [animationTarget, dispatch, nodeID, processDetailNavProps, nodeState] + [animationTarget, dispatch, nodeID, processDetailNavProps, nodeState, timestamp] ); const grandTotal: number | null = useSelector((state: ResolverState) => diff --git a/x-pack/plugins/security_solution/public/resolver/view/resolver_without_providers.tsx b/x-pack/plugins/security_solution/public/resolver/view/resolver_without_providers.tsx index a15fa03eb40ec..6fbb81a5fe0da 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/resolver_without_providers.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/resolver_without_providers.tsx @@ -25,6 +25,7 @@ import { SideEffectContext } from './side_effect_context'; import { ResolverProps, ResolverState } from '../types'; import { PanelRouter } from './panels'; import { useColors } from './use_colors'; +import { useSyncSelectedNode } from './use_sync_selected_node'; /** * The highest level connected Resolver component. Needs a `Provider` in its ancestry to work. @@ -57,6 +58,11 @@ export const ResolverWithoutProviders = React.memo( filters, }); + /** + * This will keep the selectedNode in the view in sync with the nodeID specified in the url + */ + useSyncSelectedNode(); + const { timestamp } = useContext(SideEffectContext); // use this for the entire render in order to keep things in sync diff --git a/x-pack/plugins/security_solution/public/resolver/view/submenu.tsx b/x-pack/plugins/security_solution/public/resolver/view/submenu.tsx index 9f79480770f48..77f97b947d824 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/submenu.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/submenu.tsx @@ -150,7 +150,7 @@ const NodeSubmenuPill = ({ (mouseEvent: React.MouseEvent) => { linkProps.onClick(mouseEvent); dispatch({ - type: 'userBroughtNodeIntoView', + type: 'userSelectedResolverNode', payload: { nodeID, time: timestamp(), diff --git a/x-pack/plugins/security_solution/public/resolver/view/use_camera.test.tsx b/x-pack/plugins/security_solution/public/resolver/view/use_camera.test.tsx index 1062095971723..76c70e7f4f0d6 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/use_camera.test.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/use_camera.test.tsx @@ -27,6 +27,7 @@ import * as nodeModel from '../../../common/endpoint/models/node'; import { act } from 'react-dom/test-utils'; import { mockResolverNode } from '../mocks/resolver_node'; import { endpointSourceSchema } from '../mocks/tree_schema'; +import { panAnimationDuration } from '../store/camera/scaling_constants'; describe('useCamera on an unpainted element', () => { /** Enzyme full DOM wrapper for the element the camera is attached to. */ @@ -295,7 +296,7 @@ describe('useCamera on an unpainted element', () => { throw new Error('could not find nodeID for process'); } const cameraAction: ResolverAction = { - type: 'userBroughtNodeIntoView', + type: 'userSelectedResolverNode', payload: { time: simulator.controls.time, nodeID, @@ -305,7 +306,7 @@ describe('useCamera on an unpainted element', () => { }); it('should request animation frames in a loop', () => { - const animationDuration = 1000; + const animationDuration = panAnimationDuration; // When the animation begins, the camera should request an animation frame. expect(simulator.mock.requestAnimationFrame).toHaveBeenCalledTimes(1); diff --git a/x-pack/plugins/security_solution/public/resolver/view/use_sync_selected_node.test.tsx b/x-pack/plugins/security_solution/public/resolver/view/use_sync_selected_node.test.tsx new file mode 100644 index 0000000000000..1cbe7b8d7451f --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/view/use_sync_selected_node.test.tsx @@ -0,0 +1,137 @@ +/* + * 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 { createMemoryHistory, History as HistoryPackageHistoryInterface } from 'history'; +import { noAncestorsTwoChildrenWithRelatedEventsOnOrigin } from '../data_access_layer/mocks/no_ancestors_two_children_with_related_events_on_origin'; +import { Simulator } from '../test_utilities/simulator'; +import '../test_utilities/extend_jest'; +import { urlSearch } from '../test_utilities/url_search'; +import { panAnimationDuration } from '../store/camera/scaling_constants'; + +const resolverComponentInstanceID = 'useSyncSelectedNodeTestInstanceId'; + +describe(`Resolver: when analyzing a tree with 0 ancestors, 2 children, 2 related registry events, and 1 event of each other category on the origin, with the component instance ID: ${resolverComponentInstanceID}, and the origin node selected`, () => { + let simulator: Simulator; + let memoryHistory: HistoryPackageHistoryInterface; + + // node IDs used by the generator + let entityIDs: { + origin: string; + firstChild: string; + secondChild: string; + }; + + beforeEach(() => { + const { + metadata: dataAccessLayerMetadata, + dataAccessLayer, + } = noAncestorsTwoChildrenWithRelatedEventsOnOrigin(); + + entityIDs = dataAccessLayerMetadata.entityIDs; + + memoryHistory = createMemoryHistory(); + + simulator = new Simulator({ + databaseDocumentID: dataAccessLayerMetadata.databaseDocumentID, + dataAccessLayer, + resolverComponentInstanceID, + history: memoryHistory, + indices: [], + shouldUpdate: false, + filters: {}, + }); + + const queryStringWithOriginSelected = urlSearch(resolverComponentInstanceID, { + panelParameters: { nodeID: 'origin' }, + panelView: 'nodeDetail', + }); + + memoryHistory.push({ + search: queryStringWithOriginSelected, + }); + }); + + describe('when the primary button for the first child is selected', () => { + beforeEach(async () => { + const firstChildPrimaryButton = await simulator.resolveWrapper(() => + simulator.processNodePrimaryButton(entityIDs.firstChild) + ); + + if (firstChildPrimaryButton) { + firstChildPrimaryButton.simulate('click', { button: 0 }); + simulator.runAnimationFramesTimeFromNow(panAnimationDuration); + } + }); + + it('should show the first child as the active and selected node', async () => { + await expect( + simulator.map(() => ({ + unselectedOriginCount: simulator.unselectedProcessNode(entityIDs.origin).length, + selectedFirstChildCount: simulator.selectedProcessNode(entityIDs.firstChild).length, + unselectedSecondChildCount: simulator.unselectedProcessNode(entityIDs.secondChild).length, + nodePrimaryButtonCount: simulator.testSubject('resolver:node:primary-button').length, + })) + ).toYieldEqualTo({ + unselectedOriginCount: 1, + selectedFirstChildCount: 1, + unselectedSecondChildCount: 1, + nodePrimaryButtonCount: 3, + }); + }); + + describe('when the browser is returned to the previous url where the origin was selected by triggering the back button', () => { + beforeEach(async () => { + memoryHistory.goBack(); + simulator.runAnimationFramesTimeFromNow(panAnimationDuration); + }); + + it('should show the origin node as the selected node', async () => { + await expect( + simulator.map(() => ({ + selectedOriginCount: simulator.selectedProcessNode(entityIDs.origin).length, + unselectedFirstChildCount: simulator.unselectedProcessNode(entityIDs.firstChild).length, + unselectedSecondChildCount: simulator.unselectedProcessNode(entityIDs.secondChild) + .length, + nodePrimaryButtonCount: simulator.testSubject('resolver:node:primary-button').length, + })) + ).toYieldEqualTo({ + selectedOriginCount: 1, + unselectedFirstChildCount: 1, + unselectedSecondChildCount: 1, + nodePrimaryButtonCount: 3, + }); + }); + }); + + describe('when the browser forward button is triggered after the back button is triggered to return to the first child being selected', () => { + beforeEach(async () => { + // Return back to the origin being selected + memoryHistory.goBack(); + simulator.runAnimationFramesTimeFromNow(panAnimationDuration); + + // Then hit the 'forward' button to return back to the first child being selected + memoryHistory.goForward(); + simulator.runAnimationFramesTimeFromNow(panAnimationDuration); + }); + + it('should show the firstChild node as the selected node', async () => { + await expect( + simulator.map(() => ({ + unselectedOriginCount: simulator.unselectedProcessNode(entityIDs.origin).length, + selectedFirstChildCount: simulator.selectedProcessNode(entityIDs.firstChild).length, + unselectedSecondChildCount: simulator.unselectedProcessNode(entityIDs.secondChild) + .length, + nodePrimaryButtonCount: simulator.testSubject('resolver:node:primary-button').length, + })) + ).toYieldEqualTo({ + unselectedOriginCount: 1, + selectedFirstChildCount: 1, + unselectedSecondChildCount: 1, + nodePrimaryButtonCount: 3, + }); + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/resolver/view/use_sync_selected_node.ts b/x-pack/plugins/security_solution/public/resolver/view/use_sync_selected_node.ts new file mode 100644 index 0000000000000..b432b7e0e5437 --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/view/use_sync_selected_node.ts @@ -0,0 +1,59 @@ +/* + * 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 { useContext, useEffect } from 'react'; +import { useSelector, useDispatch } from 'react-redux'; +import { useLocation } from 'react-router-dom'; +import * as selectors from '../store/selectors'; +import { SideEffectContext } from './side_effect_context'; +import { ResolverAction } from '../store/actions'; +import { panelViewAndParameters } from '../store/panel_view_and_parameters'; + +/** + * This custom hook, will maintain the state of the active/selected node with the what the selected nodeID is in url state. + * This means page refreshes, direct links, back and forward buttons, should always pan to the node defined in the url + * In the scenario where the nodeList is visible in the panel, there is no selectedNode, but this would naturally default to the origin node based on `serverReturnedResolverData` on initial load and refresh + * This custom hook should only be called once on resolver load, following that the url nodeID should always equal the selectedNode. This is currently called in `resolver_without_providers.tsx`. + */ +export function useSyncSelectedNode() { + const dispatch: (action: ResolverAction) => void = useDispatch(); + const resolverComponentInstanceID = useSelector(selectors.resolverComponentInstanceID); + const locationSearch = useLocation().search; + const sideEffectors = useContext(SideEffectContext); + const selectedNode = useSelector(selectors.selectedNode); + const idToNodeMap = useSelector(selectors.graphNodeForID); + + const currentPanelParameters = panelViewAndParameters({ + locationSearch, + resolverComponentInstanceID, + }); + + let urlNodeID: string | undefined; + + if (currentPanelParameters.panelView !== 'nodes') { + urlNodeID = currentPanelParameters.panelParameters.nodeID; + } + + useEffect(() => { + // use this for the entire render in order to keep things in sync + if (urlNodeID && idToNodeMap(urlNodeID) && urlNodeID !== selectedNode) { + dispatch({ + type: 'userSelectedResolverNode', + payload: { + nodeID: urlNodeID, + time: sideEffectors.timestamp(), + }, + }); + } + }, [ + currentPanelParameters.panelView, + urlNodeID, + dispatch, + idToNodeMap, + selectedNode, + sideEffectors, + ]); +} diff --git a/x-pack/test/plugin_functional/test_suites/resolver/index.ts b/x-pack/test/plugin_functional/test_suites/resolver/index.ts index c482bf7ae7024..61cb15c86659e 100644 --- a/x-pack/test/plugin_functional/test_suites/resolver/index.ts +++ b/x-pack/test/plugin_functional/test_suites/resolver/index.ts @@ -4,14 +4,17 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - import expect from '@kbn/expect'; import { WebElementWrapper } from '../../../../../test/functional/services/lib/web_element_wrapper'; import { FtrProviderContext } from '../../ftr_provider_context'; +import { panAnimationDuration } from '../../../../plugins/security_solution/public/resolver/store/camera/scaling_constants'; const expectedDifference = 0.09; +const waitForPanAnimationToFinish = () => + new Promise((resolve) => setTimeout(resolve, panAnimationDuration + 1)); + export default function ({ getPageObjects, getService, @@ -104,6 +107,8 @@ export default function ({ beforeEach(async () => { // select the node await button.click(); + // Wait for the pan to center the node + await waitForPanAnimationToFinish(); }); it('should render as expected', async () => { expect( @@ -156,6 +161,9 @@ export default function ({ beforeEach(async () => { // click the first pill await (await firstPill()).click(); + + // Wait for the pan to center the node + await waitForPanAnimationToFinish(); }); it('should render as expected', async () => { expect(