From 161972ef2cc319873e71467a213abc8c825a6820 Mon Sep 17 00:00:00 2001 From: Robert Austin Date: Tue, 27 Oct 2020 12:41:02 -0400 Subject: [PATCH] [Resolver] `SideEffectContext` changes, remove `@testing-library` uses (#81077) * `getBoundingClientRect` is now accessed through `SideEffectContext`. * `writeText` from the `Clipboard` API is now accessed through the `SideEffectContext` * No longer using `@testing-library/react` and `@testing-library/react-hooks` * No longer using `jest.spyOn` (mostly) or `jest.clearAllMocks` The motivation for this PR: * We already have `SideEffectContext`, which is meant to be an alternative to using `jest.spyOn`. This PR uses the `SideEffectContext` for `getBoundingClientRect` and `navigator.clipboard.writeText`. * We have been using `enzyme` lately. This removes uses of `@testing-library/react` and `@testing-library/react-hooks` in favor of `enzyme`. --- ..._children_with_related_events_on_origin.ts | 3 +- .../{get_ui_settings.ts => ui_setting.ts} | 5 +- .../public/resolver/store/actions.ts | 9 + .../resolver/test_utilities/extend_jest.ts | 6 +- .../test_utilities/simulator/index.tsx | 58 ++++- .../public/resolver/types.ts | 27 ++ .../resolver/view/clickthrough.test.tsx | 5 +- ...ntent_utilities.tsx => generated_text.tsx} | 36 +-- .../public/resolver/view/panel.test.tsx | 174 +++++++++---- .../view/panels/copyable_panel_field.tsx | 57 +++-- .../resolver/view/panels/event_detail.tsx | 43 ++-- .../resolver/view/panels/node_detail.tsx | 2 +- .../view/panels/node_events_of_type.tsx | 12 +- .../public/resolver/view/panels/node_list.tsx | 24 +- .../public/resolver/view/panels/styles.tsx | 20 ++ .../view/panels/use_formatted_date.test.tsx | 115 +++------ .../resolver/view/side_effect_context.ts | 6 + .../view/side_effect_simulator_factory.ts | 49 ++-- .../public/resolver/view/use_camera.test.tsx | 238 ++++++++++++------ .../public/resolver/view/use_camera.ts | 17 +- 20 files changed, 550 insertions(+), 356 deletions(-) rename x-pack/plugins/security_solution/public/resolver/mocks/{get_ui_settings.ts => ui_setting.ts} (79%) rename x-pack/plugins/security_solution/public/resolver/view/{panels/panel_content_utilities.tsx => generated_text.tsx} (59%) diff --git a/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/no_ancestors_two_children_with_related_events_on_origin.ts b/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/no_ancestors_two_children_with_related_events_on_origin.ts index 6fb84eaf7fda6..837d824db8748 100644 --- a/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/no_ancestors_two_children_with_related_events_on_origin.ts +++ b/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/no_ancestors_two_children_with_related_events_on_origin.ts @@ -78,8 +78,7 @@ export function noAncestorsTwoChildrenWithRelatedEventsOnOrigin(): { */ async eventsWithEntityIDAndCategory( entityID: string, - category: string, - after?: string + category: string ): Promise<{ events: SafeResolverEvent[]; nextEvent: string | null }> { const events = entityID === metadata.entityIDs.origin diff --git a/x-pack/plugins/security_solution/public/resolver/mocks/get_ui_settings.ts b/x-pack/plugins/security_solution/public/resolver/mocks/ui_setting.ts similarity index 79% rename from x-pack/plugins/security_solution/public/resolver/mocks/get_ui_settings.ts rename to x-pack/plugins/security_solution/public/resolver/mocks/ui_setting.ts index ab1a5c86859ac..4d173cd270cb8 100644 --- a/x-pack/plugins/security_solution/public/resolver/mocks/get_ui_settings.ts +++ b/x-pack/plugins/security_solution/public/resolver/mocks/ui_setting.ts @@ -4,7 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -export function getUiSettings(key: string): string | undefined { +/** + * A mock for Kibana UI settings. + */ +export function uiSetting(key: string): string | undefined { if (key === 'dateFormat') { return 'MMM D, YYYY @ HH:mm:ss.SSS'; } 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 66a32ba29cd74..26a5f8555a81b 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/actions.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/actions.ts @@ -8,16 +8,25 @@ 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; }; 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 aa04221361de0..23b651c262cba 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 @@ -34,7 +34,7 @@ expect.extend({ expected: T ): Promise<{ pass: boolean; message: () => string }> { // Used in printing out the pass or fail message - const matcherName = 'toSometimesYieldEqualTo'; + const matcherName = 'toYieldEqualTo'; const options: jest.MatcherHintOptions = { comment: 'deep equality with any yielded value', isNot: this.isNot, @@ -100,9 +100,9 @@ expect.extend({ expected: T ): Promise<{ pass: boolean; message: () => string }> { // Used in printing out the pass or fail message - const matcherName = 'toSometimesYieldEqualTo'; + const matcherName = 'toYieldObjectEqualTo'; const options: jest.MatcherHintOptions = { - comment: 'deep equality with any yielded value', + comment: 'subset equality with any yielded value', isNot: this.isNot, promise: this.promise, }; 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 2a399b6844bd7..2a538620dce0b 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 @@ -16,7 +16,7 @@ import { MockResolver } from './mock_resolver'; import { ResolverState, DataAccessLayer, SpyMiddleware, SideEffectSimulator } from '../../types'; import { ResolverAction } from '../../store/actions'; import { sideEffectSimulatorFactory } from '../../view/side_effect_simulator_factory'; -import { getUiSettings } from '../../mocks/get_ui_settings'; +import { uiSetting } from '../../mocks/ui_setting'; /** * Test a Resolver instance using jest, enzyme, and a mock data layer. @@ -62,6 +62,13 @@ export class Simulator { return selector; } + /** + * The simulator returns enzyme `ReactWrapper`s from various methods. Use this predicate to determine if they are DOM nodes. + */ + public static isDOM(wrapper: ReactWrapper): boolean { + return typeof wrapper.type() === 'string'; + } + constructor({ dataAccessLayer, resolverComponentInstanceID, @@ -110,7 +117,7 @@ export class Simulator { // Used for `KibanaContextProvider` const coreStart = coreMock.createStart(); - coreStart.uiSettings.get.mockImplementation(getUiSettings); + coreStart.uiSettings.get.mockImplementation(uiSetting); this.sideEffectSimulator = sideEffectSimulatorFactory(); @@ -190,7 +197,7 @@ export class Simulator { * After 10 times, quit. * Use this to continually check a value. See `toYieldEqualTo`. */ - public async *map(mapper: () => R): AsyncIterable { + public async *map(mapper: (() => Promise) | (() => R)): AsyncIterable { let timeoutCount = 0; while (timeoutCount < 10) { timeoutCount++; @@ -267,6 +274,20 @@ export class Simulator { this.sideEffectSimulator.controls.provideAnimationFrame(); } + /** + * The last value written to the clipboard via the `SideEffectors`. + */ + public get clipboardText(): string { + return this.sideEffectSimulator.controls.clipboardText; + } + + /** + * Call this to resolve the promise returned by the `SideEffectors` `writeText` method (which in production points to `navigator.clipboard.writeText`. + */ + confirmTextWrittenToClipboard(): void { + this.sideEffectSimulator.controls.confirmTextWrittenToClipboard(); + } + /** * The 'search' part of the URL. */ @@ -296,13 +317,36 @@ export class Simulator { return this.domNodes(`[data-test-subj="${selector}"]`); } + /** + * Given a `ReactWrapper`, returns a wrapper containing immediately following `dd` siblings. + * `subject` must contain just 1 element. + */ + public descriptionDetails(subject: ReactWrapper): ReactWrapper { + // find the associated DOM nodes, then return an enzyme wrapper that only contains those. + const subjectNode = subject.getDOMNode(); + let current = subjectNode.nextElementSibling; + const associated: Set = new Set(); + // Multiple `dt`s can be associated with a set of `dd`s. Skip immediately following `dt`s. + while (current !== null && current.nodeName === 'DT') { + current = current.nextElementSibling; + } + while (current !== null && current.nodeName === 'DD') { + associated.add(current); + current = current.nextElementSibling; + } + return subject + .closest('dl') + .find('dd') + .filterWhere((candidate) => { + return associated.has(candidate.getDOMNode()); + }); + } + /** * Return DOM nodes that match `enzymeSelector`. */ private domNodes(enzymeSelector: string): ReactWrapper { - return this.wrapper - .find(enzymeSelector) - .filterWhere((wrapper) => typeof wrapper.type() === 'string'); + return this.wrapper.find(enzymeSelector).filterWhere(Simulator.isDOM); } /** @@ -331,7 +375,7 @@ export class Simulator { * Resolve the wrapper returned by `wrapperFactory` only once it has at least 1 element in it. */ public async resolveWrapper( - wrapperFactory: () => ReactWrapper, + wrapperFactory: (() => Promise) | (() => ReactWrapper), predicate: (wrapper: ReactWrapper) => boolean = (wrapper) => wrapper.length > 0 ): Promise { for await (const wrapper of this.map(wrapperFactory)) { diff --git a/x-pack/plugins/security_solution/public/resolver/types.ts b/x-pack/plugins/security_solution/public/resolver/types.ts index fb57f85639e33..7129e3a47120a 100644 --- a/x-pack/plugins/security_solution/public/resolver/types.ts +++ b/x-pack/plugins/security_solution/public/resolver/types.ts @@ -490,9 +490,26 @@ export interface SideEffectors { * A function which returns the time since epoch in milliseconds. Injected because mocking Date is tedious. */ timestamp: () => number; + /** + * Use instead of `window.requestAnimationFrame` + **/ requestAnimationFrame: typeof window.requestAnimationFrame; + /** + * Use instead of `window.cancelAnimationFrame` + **/ cancelAnimationFrame: typeof window.cancelAnimationFrame; + /** + * Use instead of the `ResizeObserver` global. + */ ResizeObserver: ResizeObserverConstructor; + /** + * Use this instead of the Clipboard API's `writeText` method. + */ + writeTextToClipboard(text: string): Promise; + /** + * Use this instead of `Element.prototype.getBoundingClientRect` . + */ + getBoundingClientRect(element: Element): DOMRect; } export interface SideEffectSimulator { @@ -512,6 +529,16 @@ export interface SideEffectSimulator { * Trigger `ResizeObserver` callbacks for `element` and update the mocked value for `getBoundingClientRect`. */ simulateElementResize: (element: Element, contentRect: DOMRect) => void; + + /** + * Get the most recently written clipboard text. This is only updated when `confirmTextWrittenToClipboard` is called. + */ + clipboardText: string; + + /** + * Call this to resolve the promise returned by `writeText`. + */ + confirmTextWrittenToClipboard: () => void; }; /** * Mocked `SideEffectors`. diff --git a/x-pack/plugins/security_solution/public/resolver/view/clickthrough.test.tsx b/x-pack/plugins/security_solution/public/resolver/view/clickthrough.test.tsx index c781832dc8a3b..7739d81269180 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/clickthrough.test.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/clickthrough.test.tsx @@ -165,10 +165,7 @@ describe('Resolver, when analyzing a tree that has no ancestors and 2 children', ).toYieldEqualTo({ treeCount: 1, nodesOwnedByTrees: 3 }); }); - it(`should show links to the 3 nodes (with icons) in the node list.`, async () => { - await expect( - simulator.map(() => simulator.testSubject('resolver:node-list:node-link:title').length) - ).toYieldEqualTo(3); + it(`should show links to the 3 nodes in the node list.`, async () => { await expect( simulator.map(() => simulator.testSubject('resolver:node-list:node-link:title').length) ).toYieldEqualTo(3); diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_utilities.tsx b/x-pack/plugins/security_solution/public/resolver/view/generated_text.tsx similarity index 59% rename from x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_utilities.tsx rename to x-pack/plugins/security_solution/public/resolver/view/generated_text.tsx index a20498cbfb67b..61a12fa33cc9d 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_utilities.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/generated_text.tsx @@ -4,33 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +import React from 'react'; /* eslint-disable react/display-name */ - -import { EuiCode } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import styled from 'styled-components'; -import React, { memo } from 'react'; - -/** - * Text to use in place of an undefined timestamp value - */ - -export const noTimestampRetrievedText = i18n.translate( - 'xpack.securitySolution.enpdoint.resolver.panelutils.noTimestampRetrieved', - { - defaultMessage: 'No timestamp retrieved', - } -); - -/** - * A bold version of EuiCode to display certain titles with - */ -export const BoldCode = styled(EuiCode)` - &.euiCodeBlock code.euiCodeBlock__code { - font-weight: 900; - } -`; - /** * A component that renders an element with breaking opportunities (``s) * spliced into text children at word boundaries. @@ -61,12 +36,3 @@ export const GeneratedText = React.memo(function ({ children }) { }); } }); - -/** - * A component to keep time representations in blocks so they don't wrap - * and look bad. - */ -export const StyledTime = memo(styled('time')` - display: inline-block; - text-align: start; -`); 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 9d72af3109564..3b3651ec2558a 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 @@ -29,6 +29,20 @@ describe(`Resolver: when analyzing a tree with no ancestors and two children and secondChild: string; }; + /** + * These are the details we expect to see in the node detail view when the origin is selected. + */ + const originEventDetailEntries: ReadonlyMap = new Map([ + ['@timestamp', 'Sep 23, 2020 @ 08:25:32.316'], + ['process.executable', 'executable'], + ['process.pid', '0'], + ['user.name', 'user.name'], + ['user.domain', 'user.domain'], + ['process.parent.pid', '0'], + ['process.hash.md5', 'hash.md5'], + ['process.args', 'args'], + ]); + beforeEach(() => { // create a mock data access layer const { @@ -86,16 +100,7 @@ describe(`Resolver: when analyzing a tree with no ancestors and two children and ).toYieldEqualTo({ title: 'c.ext', titleIcon: 'Running Process', - detailEntries: [ - ['@timestamp', 'Sep 23, 2020 @ 08:25:32.316'], - ['process.executable', 'executable'], - ['process.pid', '0'], - ['user.name', 'user.name'], - ['user.domain', 'user.domain'], - ['process.parent.pid', '0'], - ['process.hash.md5', 'hash.md5'], - ['process.args', 'args'], - ], + detailEntries: [...originEventDetailEntries], }); }); it('should have breaking opportunities (s) in node titles to allow wrapping', async () => { @@ -111,16 +116,46 @@ describe(`Resolver: when analyzing a tree with no ancestors and two children and wordBreaks: 2, }); }); - it('should allow all node details to be copied', async () => { - const copyableFields = await simulator().resolve('resolver:panel:copyable-field'); - copyableFields?.map((copyableField) => { - copyableField.simulate('mouseenter'); - simulator().testSubject('resolver:panel:clipboard').last().simulate('click'); - expect(navigator.clipboard.writeText).toHaveBeenCalledWith(copyableField.text()); - copyableField.simulate('mouseleave'); - }); - }); + /** + * These tests use a statically defined map of fields and expected values. The test finds the `dt` for each field and then finds the related `dd`s. From there it finds a special 'hover area' (via `data-test-subj`) and simulates a `mouseenter` on it. This is because the feature work by adding event listeners to `div`s. There is no way for the user to know that the `div`s are interactable. + + * Finally the test clicks a button and checks that the clipboard was written to. + */ + describe.each([...originEventDetailEntries])( + 'when the user hovers over the description for the field (%p) with their mouse', + (fieldTitleText, value) => { + beforeEach(async () => { + const dt = await simulator().resolveWrapper(() => { + return simulator() + .testSubject('resolver:node-detail:entry-title') + .filterWhere((title) => title.text() === fieldTitleText); + }); + + expect(dt).toHaveLength(1); + + const copyableFieldHoverArea = simulator() + .descriptionDetails(dt!) + // The copyable field popup does not use a button as a trigger. It is instead triggered by mouse interaction with this `div`. + .find(`[data-test-subj="resolver:panel:copyable-field-hover-area"]`) + .filterWhere(Simulator.isDOM); + + expect(copyableFieldHoverArea).toHaveLength(1); + copyableFieldHoverArea!.simulate('mouseenter'); + }); + describe('and when they click the copy-to-clipboard button', () => { + beforeEach(async () => { + const copyButton = await simulator().resolve('resolver:panel:clipboard'); + expect(copyButton).toHaveLength(1); + copyButton!.simulate('click'); + simulator().confirmTextWrittenToClipboard(); + }); + it(`should write ${value} to the clipboard`, async () => { + await expect(simulator().map(() => simulator().clipboardText)).toYieldEqualTo(value); + }); + }); + } + ); }); const queryStringWithFirstChildSelected = urlSearch(resolverComponentInstanceID, { @@ -160,23 +195,43 @@ describe(`Resolver: when analyzing a tree with no ancestors and two children and it('should have 3 nodes (with icons) in the node list', async () => { 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); + simulator().map(() => { + return { + titleCount: simulator().testSubject('resolver:node-list:node-link:title').length, + iconCount: simulator().testSubject('resolver:node-list:node-link:icon').length, + }; + }) + ).toYieldEqualTo({ titleCount: 3, iconCount: 3 }); }); - it('should be able to copy the timestamps for all 3 nodes', async () => { - const copyableFields = await simulator().resolve('resolver:panel:copyable-field'); + describe('when the user hovers over the timestamp for "c.ext" with their mouse', () => { + beforeEach(async () => { + const cExtHoverArea = await simulator().resolveWrapper(async () => { + const nodeLinkTitles = await simulator().resolve('resolver:node-list:node-link:title'); - expect(copyableFields?.length).toBe(3); + expect(nodeLinkTitles).toHaveLength(3); - copyableFields?.map((copyableField) => { - copyableField.simulate('mouseenter'); - simulator().testSubject('resolver:panel:clipboard').last().simulate('click'); - expect(navigator.clipboard.writeText).toHaveBeenCalledWith(copyableField.text()); - copyableField.simulate('mouseleave'); + return ( + nodeLinkTitles! + .filterWhere((linkTitle) => linkTitle.text() === 'c.ext') + // Find the parent `tr` and the find all hover areas in that TR. The test assumes that all cells in a row are associated. + .closest('tr') + // The copyable field popup does not use a button as a trigger. It is instead triggered by mouse interaction with this `div`. + .find('[data-test-subj="resolver:panel:copyable-field-hover-area"]') + .filterWhere(Simulator.isDOM) + ); + }); + cExtHoverArea!.simulate('mouseenter'); + }); + describe('and when the user clicks the copy-to-clipboard button', () => { + beforeEach(async () => { + (await simulator().resolve('resolver:panel:clipboard'))!.simulate('click'); + simulator().confirmTextWrittenToClipboard(); + }); + const expected = 'Sep 23, 2020 @ 08:25:32.316'; + it(`should write "${expected}" to the clipboard`, async () => { + await expect(simulator().map(() => simulator().clipboardText)).toYieldEqualTo(expected); + }); }); }); @@ -191,16 +246,7 @@ describe(`Resolver: when analyzing a tree with no ancestors and two children and it('should show the details for the first node', async () => { await expect( simulator().map(() => simulator().nodeDetailDescriptionListEntries()) - ).toYieldEqualTo([ - ['@timestamp', 'Sep 23, 2020 @ 08:25:32.316'], - ['process.executable', 'executable'], - ['process.pid', '0'], - ['user.name', 'user.name'], - ['user.domain', 'user.domain'], - ['process.parent.pid', '0'], - ['process.hash.md5', 'hash.md5'], - ['process.args', 'args'], - ]); + ).toYieldEqualTo([...originEventDetailEntries]); }); it("should have the first node's ID in the query string", async () => { await expect(simulator().map(() => simulator().historyLocationSearch)).toYieldEqualTo( @@ -278,16 +324,40 @@ describe(`Resolver: when analyzing a tree with no ancestors and two children and simulator().map(() => simulator().testSubject('resolver:panel:event-detail').length) ).toYieldEqualTo(1); }); - it('should allow all fields to be copied', async () => { - const copyableFields = await simulator().resolve('resolver:panel:copyable-field'); - - copyableFields?.map((copyableField) => { - copyableField.simulate('mouseenter'); - simulator().testSubject('resolver:panel:clipboard').last().simulate('click'); - expect(navigator.clipboard.writeText).toHaveBeenCalledWith(copyableField.text()); - copyableField.simulate('mouseleave'); - }); - }); + describe.each([['user.domain', 'user.domain']])( + 'when the user hovers over the description for the field "%p"', + (fieldName, expectedValue) => { + beforeEach(async () => { + const fieldHoverArea = await simulator().resolveWrapper(async () => { + const dt = ( + await simulator().resolve('resolver:panel:event-detail:event-field-title') + )?.filterWhere((title) => title.text() === fieldName); + return ( + simulator() + .descriptionDetails(dt!) + // The copyable field popup does not use a button as a trigger. It is instead triggered by mouse interaction with this `div`. + .find(`[data-test-subj="resolver:panel:copyable-field-hover-area"]`) + .filterWhere(Simulator.isDOM) + ); + }); + expect(fieldHoverArea).toBeTruthy(); + fieldHoverArea?.simulate('mouseenter'); + }); + describe('when the user clicks on the clipboard button', () => { + beforeEach(async () => { + const button = await simulator().resolve('resolver:panel:clipboard'); + expect(button).toBeTruthy(); + button!.simulate('click'); + simulator().confirmTextWrittenToClipboard(); + }); + it(`should write ${expectedValue} to the clipboard`, async () => { + await expect(simulator().map(() => simulator().clipboardText)).toYieldEqualTo( + expectedValue + ); + }); + }); + } + ); }); }); }); diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/copyable_panel_field.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/copyable_panel_field.tsx index f6a585ea566bb..6a1667a839548 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/copyable_panel_field.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/copyable_panel_field.tsx @@ -9,10 +9,11 @@ import { EuiToolTip, EuiButtonIcon, EuiPopover } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import styled from 'styled-components'; -import React, { memo, useState, useCallback } from 'react'; +import React, { memo, useState, useCallback, useContext, useMemo } from 'react'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { useColors } from '../use_colors'; import { StyledPanel } from '../styles'; +import { SideEffectContext } from '../side_effect_context'; interface StyledCopyableField { readonly backgroundColor: string; @@ -48,39 +49,41 @@ export const CopyablePanelField = memo( const onMouseEnter = () => setIsOpen(true); const onMouseLeave = () => setIsOpen(false); - const ButtonContent = memo(() => ( - - {content} - - )); + const hoverArea = useMemo( + () => ( + + {content} + + ), + [content, copyableFieldBackground, linkColor] + ); - const onClick = useCallback( - async (event: React.MouseEvent) => { - try { - await navigator.clipboard.writeText(textToCopy); - } catch (error) { - if (toasts) { - toasts.addError(error, { - title: i18n.translate('xpack.securitySolution.resolver.panel.copyFailureTitle', { - defaultMessage: 'Copy Failure', - }), - }); - } + const { writeTextToClipboard } = useContext(SideEffectContext); + + const onClick = useCallback(async () => { + try { + await writeTextToClipboard(textToCopy); + } catch (error) { + if (toasts) { + toasts.addError(error, { + title: i18n.translate('xpack.securitySolution.resolver.panel.copyFailureTitle', { + defaultMessage: 'Copy Failure', + }), + }); } - }, - [textToCopy, toasts] - ); + } + }, [textToCopy, toasts, writeTextToClipboard]); return (
} + button={hoverArea} closePopover={onMouseLeave} hasArrow={false} isOpen={isOpen} diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/event_detail.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/event_detail.tsx index e5569b30abb9d..4936cf0cbb80e 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/event_detail.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/event_detail.tsx @@ -15,12 +15,8 @@ import { EuiSpacer, EuiText, EuiDescriptionList, EuiTextColor, EuiTitle } from ' import styled from 'styled-components'; import { useSelector } from 'react-redux'; import { StyledPanel } from '../styles'; -import { - BoldCode, - StyledTime, - GeneratedText, - noTimestampRetrievedText, -} from './panel_content_utilities'; +import { BoldCode, StyledTime } from './styles'; +import { GeneratedText } from '../generated_text'; import { CopyablePanelField } from './copyable_panel_field'; import { Breadcrumbs } from './breadcrumbs'; import * as eventModel from '../../../../common/endpoint/models/event'; @@ -97,7 +93,12 @@ const EventDetailContents = memo(function ({ processEvent: SafeResolverEvent | null; }) { const timestamp = eventModel.timestampSafeVersion(event); - const formattedDate = useFormattedDate(timestamp) || noTimestampRetrievedText; + const formattedDate = + useFormattedDate(timestamp) || + i18n.translate('xpack.securitySolution.enpdoint.resolver.panelutils.noTimestampRetrieved', { + defaultMessage: 'No timestamp retrieved', + }); + const nodeName = processEvent ? eventModel.processNameSafeVersion(processEvent) : null; return ( @@ -155,15 +156,20 @@ function EventDetailFields({ event }: { event: SafeResolverEvent }) { const section = { // Group the fields by their top-level namespace namespace: {key}, - descriptions: deepObjectEntries(value).map(([path, fieldValue]) => ({ - title: {path.join('.')}, - description: ( - {String(fieldValue)}} - /> - ), - })), + descriptions: deepObjectEntries(value).map(([path, fieldValue]) => { + // The field name is the 'namespace' key as well as the rest of the path, joined with '.' + const fieldName = [key, ...path].join('.'); + + return { + title: {fieldName}, + description: ( + {String(fieldValue)}} + /> + ), + }; + }), }; returnValue.push(section); } @@ -187,7 +193,10 @@ function EventDetailFields({ event }: { event: SafeResolverEvent }) { diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/node_detail.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/node_detail.tsx index c7d4f8632659b..5675e29fc2bc1 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/node_detail.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/node_detail.tsx @@ -16,7 +16,7 @@ import { EuiDescriptionListProps } from '@elastic/eui/src/components/description import { StyledDescriptionList, StyledTitle } from './styles'; import * as selectors from '../../store/selectors'; import * as eventModel from '../../../../common/endpoint/models/event'; -import { GeneratedText } from './panel_content_utilities'; +import { GeneratedText } from '../generated_text'; import { CopyablePanelField } from './copyable_panel_field'; import { Breadcrumbs } from './breadcrumbs'; import { processPath, processPID } from '../../models/process_event'; diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/node_events_of_type.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/node_events_of_type.tsx index 17e91902d0c96..c9648c6f562e5 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/node_events_of_type.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/node_events_of_type.tsx @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +/* eslint-disable react/display-name */ + import React, { memo, useCallback, Fragment } from 'react'; import { i18n } from '@kbn/i18n'; import { @@ -18,7 +20,7 @@ import { import { useSelector } from 'react-redux'; import { FormattedMessage } from '@kbn/i18n/react'; import { StyledPanel } from '../styles'; -import { BoldCode, noTimestampRetrievedText, StyledTime } from './panel_content_utilities'; +import { BoldCode, StyledTime } from './styles'; import { Breadcrumbs } from './breadcrumbs'; import * as eventModel from '../../../../common/endpoint/models/event'; import { SafeResolverEvent } from '../../../../common/endpoint/types'; @@ -99,8 +101,6 @@ export const NodeEventsInCategory = memo(function ({ ); }); -NodeEventsInCategory.displayName = 'NodeEventsInCategory'; - /** * Rendered for each event in the list. */ @@ -114,7 +114,11 @@ const NodeEventsListItem = memo(function ({ eventCategory: string; }) { const timestamp = eventModel.eventTimestamp(event); - const date = useFormattedDate(timestamp) || noTimestampRetrievedText; + const date = + useFormattedDate(timestamp) || + i18n.translate('xpack.securitySolution.enpdoint.resolver.panelutils.noTimestampRetrieved', { + defaultMessage: 'No timestamp retrieved', + }); const linkProps = useLinkProps({ panelView: 'eventDetail', panelParameters: { 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 9ef72c414bb63..e53cd2cc0860d 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 @@ -38,17 +38,14 @@ import { LimitWarning } from '../limit_warnings'; import { ResolverState } from '../../types'; import { useLinkProps } from '../use_link_props'; import { useColors } from '../use_colors'; -import { SafeResolverEvent } from '../../../../common/endpoint/types'; import { ResolverAction } from '../../store/actions'; import { useFormattedDate } from './use_formatted_date'; -import { getEmptyTagValue } from '../../../common/components/empty_value'; import { CopyablePanelField } from './copyable_panel_field'; interface ProcessTableView { name?: string; timestamp?: Date; nodeID: string; - event: SafeResolverEvent; } /** @@ -68,7 +65,7 @@ export const NodeList = memo(() => { sortable: true, truncateText: true, render(name: string | undefined, item: ProcessTableView) { - return ; + return ; }, }, { @@ -101,7 +98,6 @@ export const NodeList = memo(() => { name, timestamp: eventModel.timestampAsDateSafeVersion(processEvent), nodeID, - event: processEvent, }); } } @@ -111,7 +107,7 @@ export const NodeList = memo(() => { const numberOfProcesses = processTableView.length; - const crumbs = useMemo(() => { + const breadcrumbs = useMemo(() => { return [ { text: i18n.translate('xpack.securitySolution.resolver.panel.nodeList.title', { @@ -127,7 +123,7 @@ export const NodeList = memo(() => { const rowProps = useMemo(() => ({ 'data-test-subj': 'resolver:node-list:item' }), []); return ( - + {showWarning && } @@ -141,15 +137,7 @@ export const NodeList = memo(() => { ); }); -function NodeDetailLink({ - name, - nodeID, - event, -}: { - name?: string; - nodeID: string; - event: SafeResolverEvent; -}) { +function NodeDetailLink({ name, nodeID }: { name?: string; nodeID: string }) { const isOrigin = useSelector((state: ResolverState) => { return selectors.originID(state) === nodeID; }); @@ -175,7 +163,7 @@ function NodeDetailLink({ ); return ( - {name === '' ? ( + {name === undefined ? ( {i18n.translate( 'xpack.securitySolution.endpoint.resolver.panel.table.row.valueMissingDescription', @@ -218,6 +206,6 @@ const NodeDetailTimestamp = memo(({ eventDate }: { eventDate: Date | undefined } return formattedDate ? ( ) : ( - getEmptyTagValue() + {'—'} ); }); diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/styles.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/styles.tsx index 03826dd38397b..6f9d4bb600fde 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/styles.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/styles.tsx @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +import { EuiCode } from '@elastic/eui'; + /* eslint-disable no-duplicate-imports */ import { EuiBreadcrumbs } from '@elastic/eui'; @@ -89,3 +91,21 @@ export const StyledLabelContainer = styled.div` white-space: nowrap; } `; + +/** + * A bold version of EuiCode to display certain titles with + */ +export const BoldCode = styled(EuiCode)` + &.euiCodeBlock code.euiCodeBlock__code { + font-weight: 900; + } +`; + +/** + * A component to keep time representations in blocks so they don't wrap + * and look bad. + */ +export const StyledTime = styled('time')` + display: inline-block; + text-align: start; +`; diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/use_formatted_date.test.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/use_formatted_date.test.tsx index c08c3b370558b..647f7c75d0298 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/use_formatted_date.test.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/use_formatted_date.test.tsx @@ -4,103 +4,52 @@ * you may not use this file except in compliance with the Elastic License. */ +import { mount } from 'enzyme'; + import React from 'react'; -import { render, RenderResult } from '@testing-library/react'; import { useFormattedDate } from './use_formatted_date'; import { coreMock } from '../../../../../../../src/core/public/mocks'; import { KibanaContextProvider } from '../../../../../../../src/plugins/kibana_react/public'; -import { getUiSettings } from '../../mocks/get_ui_settings'; +import { uiSetting } from '../../mocks/ui_setting'; -describe('useFormattedDate', () => { - let element: HTMLElement; - const testID = 'formattedDate'; - let reactRenderResult: ( - date: ConstructorParameters[0] | Date | undefined - ) => RenderResult; +describe(`useFormattedDate, when the "dateFormat" UI setting is "${uiSetting( + 'dateFormat' +)}" and the "dateFormat:tz" setting is "${uiSetting('dateFormat:tz')}"`, () => { + let formattedDate: (date: ConstructorParameters[0] | Date | undefined) => string; beforeEach(async () => { const mockCoreStart = coreMock.createStart(); - mockCoreStart.uiSettings.get.mockImplementation(getUiSettings); + mockCoreStart.uiSettings.get.mockImplementation(uiSetting); function Test({ date }: { date: ConstructorParameters[0] | Date | undefined }) { - const formattedDate = useFormattedDate(date); - return
{formattedDate}
; + return <>{useFormattedDate(date)}; } - reactRenderResult = ( - date: ConstructorParameters[0] | Date | undefined - ): RenderResult => - render( + formattedDate = (date: ConstructorParameters[0] | Date | undefined): string => + mount( - ); - }); - afterEach(() => { - jest.clearAllMocks(); - }); - - describe('when the provided date is undefined', () => { - it('should return undefined', async () => { - const { findByTestId } = reactRenderResult(undefined); - element = await findByTestId(testID); - - expect(element).toBeEmptyDOMElement(); - }); - }); - - describe('when the provided date is empty', () => { - it('should return undefined', async () => { - const { findByTestId } = reactRenderResult(''); - element = await findByTestId(testID); - - expect(element).toBeEmptyDOMElement(); - }); - }); - - describe('when the provided date is an invalid date', () => { - it('should return the string invalid date', async () => { - const { findByTestId } = reactRenderResult('randomString'); - element = await findByTestId(testID); - - expect(element).toHaveTextContent('Invalid Date'); - }); - }); - - describe('when the provided date is a stringified unix timestamp', () => { - it('should return the string invalid date', async () => { - const { findByTestId } = reactRenderResult('1600863932316'); - element = await findByTestId(testID); - - expect(element).toHaveTextContent('Invalid Date'); - }); - }); - - describe('when the provided date is a valid numerical timestamp', () => { - it('should return the string invalid date', async () => { - const { findByTestId } = reactRenderResult(1600863932316); - element = await findByTestId(testID); - - expect(element).toHaveTextContent('Sep 23, 2020 @ 08:25:32.316'); - }); - }); - - describe('when the provided date is a date string', () => { - it('should return the string invalid date', async () => { - const { findByTestId } = reactRenderResult('2020-09-23T12:25:32Z'); - element = await findByTestId(testID); - - expect(element).toHaveTextContent('Sep 23, 2020 @ 08:25:32.000'); - }); - }); - - describe('when the provided date is a valid date', () => { - it('should return the string invalid date', async () => { - const validDate = new Date(1600863932316); - const { findByTestId } = reactRenderResult(validDate); - element = await findByTestId(testID); - - expect(element).toHaveTextContent('Sep 23, 2020 @ 08:25:32.316'); - }); + ).text(); + }); + + it.each([ + ['randomString', 'an invalid string', 'Invalid Date'], + [ + '1600863932316', + "a string that does't match the configured time format settings", + 'Invalid Date', + ], + [1600863932316, 'a valid unix timestamp', 'Sep 23, 2020 @ 08:25:32.316'], + [undefined, 'undefined', ''], + ['', 'an empty string', ''], + [ + '2020-09-23T12:25:32Z', + 'a string that conforms to the specified format', + 'Sep 23, 2020 @ 08:25:32.000', + ], + [new Date(1600863932316), 'a defined Date object', 'Sep 23, 2020 @ 08:25:32.316'], + ])('when the provided date is %p (%s) it should return %p', (value, _explanation, expected) => { + expect(formattedDate(value)).toBe(expected); }); }); diff --git a/x-pack/plugins/security_solution/public/resolver/view/side_effect_context.ts b/x-pack/plugins/security_solution/public/resolver/view/side_effect_context.ts index ab7f41d815026..71b054948160e 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/side_effect_context.ts +++ b/x-pack/plugins/security_solution/public/resolver/view/side_effect_context.ts @@ -19,6 +19,12 @@ const sideEffectors: SideEffectors = { return window.cancelAnimationFrame(...args); }, ResizeObserver, + writeTextToClipboard(text: string): Promise { + return navigator.clipboard.writeText(text); + }, + getBoundingClientRect(element: Element): DOMRect { + return element.getBoundingClientRect(); + }, }; /** diff --git a/x-pack/plugins/security_solution/public/resolver/view/side_effect_simulator_factory.ts b/x-pack/plugins/security_solution/public/resolver/view/side_effect_simulator_factory.ts index 8517459b8aba3..84da2824962d6 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/side_effect_simulator_factory.ts +++ b/x-pack/plugins/security_solution/public/resolver/view/side_effect_simulator_factory.ts @@ -37,7 +37,7 @@ export const sideEffectSimulatorFactory: () => SideEffectSimulator = () => { /** * Get the simulate `DOMRect` for `element`. */ - const contentRectForElement: (target: Element) => DOMRect = (target) => { + const getBoundingClientRect: (target: Element) => DOMRect = (target) => { if (contentRects.has(target)) { return contentRects.get(target)!; } @@ -58,26 +58,33 @@ export const sideEffectSimulatorFactory: () => SideEffectSimulator = () => { }; /** - * Change `Element.prototype.getBoundingClientRect` to return our faked values. + * Last value written to the clipboard, of '' if no text has been written. Returned by the `controls`. */ - jest - .spyOn(Element.prototype, 'getBoundingClientRect') - .mockImplementation(function (this: Element) { - return contentRectForElement(this); - }); + let clipboardText: string = ''; // the `readText` method of the Clipboard API returns an empty string if the clipboard is empty. + + function confirmTextWrittenToClipboard() { + const next = clipboardWriteTextQueue.shift(); + if (next) { + const [text, resolve] = next; + clipboardText = text; + resolve(); + } + } /** - * Mock the global writeText method as it is not available in jsDOM and alows us to track what was copied + * Queue of `text` waiting to be written to the clipboard. Calling `resolve` will resolve the promise returned by the mock `writeTextToClipboard` method. */ - const MockClipboard: Clipboard = { - writeText: jest.fn(), - readText: jest.fn(), - addEventListener: jest.fn(), - dispatchEvent: jest.fn(), - removeEventListener: jest.fn(), - }; - // @ts-ignore navigator doesn't natively exist on global - global.navigator.clipboard = MockClipboard; + const clipboardWriteTextQueue: Array<[text: string, resolve: () => void]> = []; + + /** + * Mock `writeText` method of the `Clipboard` API. + */ + function writeTextToClipboard(text: string): Promise { + return new Promise((resolve) => { + clipboardWriteTextQueue.push([text, resolve]); + }); + } + /** * A mock implementation of `ResizeObserver` that works with our fake `getBoundingClientRect` and `simulateElementResize` */ @@ -171,12 +178,20 @@ export const sideEffectSimulatorFactory: () => SideEffectSimulator = () => { }, simulateElementResize, + + get clipboardText() { + return clipboardText; + }, + + confirmTextWrittenToClipboard, }, mock: { requestAnimationFrame, cancelAnimationFrame, timestamp, ResizeObserver: MockResizeObserver, + writeTextToClipboard, + getBoundingClientRect, }, }; return retval; 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 bf72a52559cbd..35cf2c36d6627 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 @@ -4,13 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { FunctionComponent } from 'react'; -import { render, waitFor, RenderResult, fireEvent } from '@testing-library/react'; -import { renderHook, act } from '@testing-library/react-hooks'; -import { useCamera, useAutoUpdatingClientRect } from './use_camera'; +// Extend jest with a custom matcher +import '../test_utilities/extend_jest'; + +import { mount, ReactWrapper } from 'enzyme'; +import React from 'react'; +import { useCamera } from './use_camera'; import { Provider } from 'react-redux'; import * as selectors from '../store/selectors'; -import { Matrix3, ResolverStore, SideEffectSimulator } from '../types'; +import { Matrix3, ResolverStore, SideEffectors, SideEffectSimulator } from '../types'; import { SafeResolverEvent } from '../../../common/endpoint/types'; import { SideEffectContext } from './side_effect_context'; import { applyMatrix3 } from '../models/vector2'; @@ -22,45 +24,120 @@ import { createStore } from 'redux'; import { resolverReducer } from '../store/reducer'; import { mockTreeFetcherParameters } from '../mocks/tree_fetcher_parameters'; import { entityIDSafeVersion } from '../../../common/endpoint/models/event'; +import { act } from 'react-dom/test-utils'; describe('useCamera on an unpainted element', () => { - let element: HTMLElement; + /** Enzyme full DOM wrapper for the element the camera is attached to. */ + let element: ReactWrapper; + /** + * Enzyme full DOM wrapper for the alternate element that the camera can be attached to. Used for testing that the `ResizeObserver` attaches itself to the latest `ref`. + */ + let alternateElement: ReactWrapper; + /** + * projection matrix returned by camera on last render. + */ let projectionMatrix: Matrix3; + /** + * A `data-test-subj` ID used to identify the element the camera normally attaches to. + */ const testID = 'camera'; - let reactRenderResult: RenderResult; + /** + * A `data-test-subj` ID used to identify the element the camera alternatively attaches to. + */ + const alternateTestID = 'alternate'; + /** + * Returned by the legacy framework's render/mount function. + */ + let wrapper: ReactWrapper; let store: ResolverStore; let simulator: SideEffectSimulator; - beforeEach(async () => { - store = createStore(resolverReducer); - - const Test = function () { - const camera = useCamera(); - const { ref, onMouseDown } = camera; - projectionMatrix = camera.projectionMatrix; - return
; - }; + /** Used to find an element by the data-test-subj attribute. + */ + let domElementByTestSubj: (testSubj: string) => ReactWrapper; - simulator = sideEffectSimulatorFactory(); + /** + * Yield the result of `mapper` over and over, once per event-loop cycle. + * After 10 times, quit. + * Use this to continually check a value. See `toYieldEqualTo`. + */ + async function* map(mapper: () => R): AsyncIterable { + let timeoutCount = 0; + while (timeoutCount < 10) { + timeoutCount++; + yield mapper(); + await new Promise((resolve) => { + setTimeout(() => { + wrapper.update(); + resolve(); + }, 0); + }); + } + } - reactRenderResult = render( - - - + function TestWrapper({ + useSecondElement: useAlternateElement = false, + resolverStore, + sideEffectors, + }: { + /** + * Pass `true`, to attach the camera to an alternate element. Used to test that the `ResizeObserver` attaches itself to the latest `ref`. + */ + useSecondElement?: boolean; + resolverStore: ResolverStore; + sideEffectors: SideEffectors; + }) { + return ( + + + ); + } - const { findByTestId } = reactRenderResult; - element = await findByTestId(testID); - }); - afterEach(() => { - jest.clearAllMocks(); - }); - it('should be usable in React', async () => { - expect(element).toBeInTheDocument(); + function Test({ + useAlternateElement = false, + }: { + /** + * Pass `true`, to attach the camera to an alternate element. Used to test that the `ResizeObserver` attaches itself to the latest `ref`. + */ + useAlternateElement?: boolean; + }) { + const camera = useCamera(); + const { ref, onMouseDown } = camera; + projectionMatrix = camera.projectionMatrix; + return useAlternateElement ? ( + <> +
+
+ + ) : ( + <> +
+
+ + ); + } + + beforeEach(async () => { + store = createStore(resolverReducer); + + simulator = sideEffectSimulatorFactory(); + + wrapper = mount(); + + domElementByTestSubj = (testSubj: string) => + wrapper + .find(`[data-test-subj="${testSubj}"]`) + // Omit React components that may be returned. + .filterWhere((item) => typeof item.type() === 'string'); + + element = domElementByTestSubj(testID); + + alternateElement = domElementByTestSubj(alternateTestID); }); - test('returns a projectionMatrix that changes everything to 0', () => { + it('returns a projectionMatrix that changes everything to 0', () => { expect(applyMatrix3([0, 0], projectionMatrix)).toEqual([0, 0]); }); describe('which has been resized to 800x600', () => { @@ -71,8 +148,8 @@ describe('useCamera on an unpainted element', () => { const centerX = width / 2 + leftMargin; const centerY = height / 2 + topMargin; beforeEach(async () => { - await waitFor(() => { - simulator.controls.simulateElementResize(element, { + act(() => { + simulator.controls.simulateElementResize(element.getDOMNode(), { width, height, left: leftMargin, @@ -87,73 +164,82 @@ describe('useCamera on an unpainted element', () => { }); }); }); - test('should observe all resize reference changes', async () => { - const wrapper: FunctionComponent = ({ children }) => ( - - {children} - - ); - - const { result } = renderHook(() => useAutoUpdatingClientRect(), { wrapper }); - const resizeObserverSpy = jest.spyOn(simulator.mock.ResizeObserver.prototype, 'observe'); - - let [rect, ref] = result.current; - act(() => ref(element)); - expect(resizeObserverSpy).toHaveBeenCalledWith(element); - - const div = document.createElement('div'); - act(() => ref(div)); - expect(resizeObserverSpy).toHaveBeenCalledWith(div); - - [rect, ref] = result.current; - expect(rect?.width).toBe(0); - }); - test('provides a projection matrix that inverts the y axis and translates 400,300 (center of the element)', () => { - expect(applyMatrix3([0, 0], projectionMatrix)).toEqual([400, 300]); + it('provides a projection matrix that inverts the y axis and translates 400,300 (center of the element)', () => { + expect(map(() => applyMatrix3([0, 0], projectionMatrix))).toYieldEqualTo([400, 300]); }); describe('when the user presses the mousedown button in the middle of the element', () => { beforeEach(() => { - fireEvent.mouseDown(element, { + element.simulate('mousedown', { clientX: centerX, clientY: centerY, }); }); describe('when the user moves the mouse 50 pixels to the right', () => { beforeEach(() => { - fireEvent.mouseMove(element, { + element.simulate('mousemove', { clientX: centerX + 50, clientY: centerY, }); }); it('should project [0, 0] in world corrdinates 50 pixels to the right of the center of the element', () => { - expect(applyMatrix3([0, 0], projectionMatrix)).toEqual([450, 300]); + expect(map(() => applyMatrix3([0, 0], projectionMatrix))).toYieldEqualTo([450, 300]); }); }); }); describe('when the user uses the mousewheel w/ ctrl held down', () => { beforeEach(() => { - fireEvent.wheel(element, { + element.simulate('wheel', { ctrlKey: true, deltaY: -10, deltaMode: 0, }); }); it('should zoom in', () => { - expect(projectionMatrix).toMatchInlineSnapshot(` - Array [ - 1.0292841801261479, - 0, - 400, - 0, - -1.0292841801261479, - 300, - 0, - 0, - 0, - ] - `); + expect(map(() => projectionMatrix)).toYieldEqualTo([ + 1.0292841801261479, + 0, + 400, + 0, + -1.0292841801261479, + 300, + 0, + 0, + 0, + ]); + }); + }); + + describe('when the element the camera is attached to is switched', () => { + beforeEach(() => { + wrapper.setProps({ + useAlternateElement: true, + }); + }); + describe('and when that element changes size to 1200x800', () => { + beforeEach(() => { + act(() => { + const alternateElementWidth = 1200; + const alternateElementHeight = 800; + simulator.controls.simulateElementResize(alternateElement.getDOMNode(), { + width: alternateElementWidth, + height: alternateElementHeight, + left: leftMargin, + top: topMargin, + right: leftMargin + alternateElementWidth, + bottom: topMargin + alternateElementHeight, + x: leftMargin, + y: topMargin, + toJSON() { + return this; + }, + }); + }); + }); + it('provides a projection matrix that inverts the y axis and translates 600,400', () => { + expect(map(() => applyMatrix3([0, 0], projectionMatrix))).toYieldEqualTo([600, 400]); + }); }); }); @@ -185,9 +271,7 @@ describe('useCamera on an unpainted element', () => { type: 'serverReturnedResolverData', payload: { result: tree, parameters: mockTreeFetcherParameters() }, }; - await waitFor(() => { - store.dispatch(serverResponseAction); - }); + store.dispatch(serverResponseAction); } else { throw new Error('failed to create tree'); } @@ -210,9 +294,7 @@ describe('useCamera on an unpainted element', () => { nodeID, }, }; - await waitFor(() => { - store.dispatch(cameraAction); - }); + store.dispatch(cameraAction); }); it('should request animation frames in a loop', () => { diff --git a/x-pack/plugins/security_solution/public/resolver/view/use_camera.ts b/x-pack/plugins/security_solution/public/resolver/view/use_camera.ts index 661e038d04e32..c58b9f77d097d 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/use_camera.ts +++ b/x-pack/plugins/security_solution/public/resolver/view/use_camera.ts @@ -280,7 +280,10 @@ export function useCamera(): { * tracked. So if the element's position moves for some reason, be sure to * handle that. */ -export function useAutoUpdatingClientRect(): [DOMRect | null, (node: Element | null) => void] { +function useAutoUpdatingClientRect(): [DOMRect | null, (node: Element | null) => void] { + // Access `getBoundingClientRect` via the `SideEffectContext` (for testing.) + const { getBoundingClientRect } = useContext(SideEffectContext); + // This hooks returns `rect`. const [rect, setRect] = useState(null); @@ -302,9 +305,9 @@ export function useAutoUpdatingClientRect(): [DOMRect | null, (node: Element | n useEffect(() => { if (currentNode !== null) { // When the DOM node is received, immedaiately calculate its DOM Rect and return that - setRect(currentNode.getBoundingClientRect()); + setRect(getBoundingClientRect(currentNode)); } - }, [currentNode]); + }, [currentNode, getBoundingClientRect]); /** * When scroll events occur, recalculate the DOMRect. DOMRect represents the position of an element relative to the viewport, so that may change during scroll (depending on the layout.) @@ -322,7 +325,7 @@ export function useAutoUpdatingClientRect(): [DOMRect | null, (node: Element | n const currentY = window.scrollY; if (currentNode !== null && (previousX !== currentX || previousY !== currentY)) { - setRect(currentNode.getBoundingClientRect()); + setRect(getBoundingClientRect(currentNode)); } previousX = currentX; @@ -334,13 +337,13 @@ export function useAutoUpdatingClientRect(): [DOMRect | null, (node: Element | n return () => { window.removeEventListener('scroll', handleScroll); }; - }, [currentNode, requestAnimationFrame]); + }, [currentNode, requestAnimationFrame, getBoundingClientRect]); useEffect(() => { if (currentNode !== null) { const resizeObserver = new ResizeObserver((entries) => { if (currentNode !== null && currentNode === entries[0].target) { - setRect(currentNode.getBoundingClientRect()); + setRect(getBoundingClientRect(currentNode)); } }); resizeObserver.observe(currentNode); @@ -348,6 +351,6 @@ export function useAutoUpdatingClientRect(): [DOMRect | null, (node: Element | n resizeObserver.disconnect(); }; } - }, [ResizeObserver, currentNode]); + }, [ResizeObserver, currentNode, getBoundingClientRect]); return [rect, ref]; }