[Security Solution][Endpoint] Additional test coverage for the Endpoint Console (#151504)

## Summary

- Adds more tests for covering functionality provided by the Response
console

Address TestRail tests:

- 1948002
- 1948003
- 1948004
- 1948005
- 1948007
- 1948008
This commit is contained in:
Paul Tavares 2023-02-23 15:58:38 -05:00 committed by GitHub
parent f2d33f13e1
commit e0bc286a75
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 662 additions and 79 deletions

View file

@ -24,6 +24,7 @@ import type {
} from '@testing-library/react-hooks/src/types/react';
import type { UseBaseQueryResult } from '@tanstack/react-query';
import ReactDOM from 'react-dom';
import { ExperimentalFeaturesService } from '../../experimental_features_service';
import { applyIntersectionObserverMock } from '../intersection_observer_mock';
import { ConsoleManager } from '../../../management/components/console';
import type { StartPlugins, StartServices } from '../../../types';
@ -42,6 +43,7 @@ import { APP_UI_ID, APP_PATH } from '../../../../common/constants';
import { KibanaContextProvider, KibanaServices } from '../../lib/kibana';
import { getDeepLinks } from '../../../app/deep_links';
import { fleetGetPackageHttpMock } from '../../../management/mocks';
import { allowedExperimentalValues } from '../../../../common/experimental_features';
const REAL_REACT_DOM_CREATE_PORTAL = ReactDOM.createPortal;
@ -282,7 +284,16 @@ export const createAppRootMockRenderer = (): AppContextTestRender => {
return hookResult.current;
};
ExperimentalFeaturesService.init({ experimentalFeatures: allowedExperimentalValues });
const setExperimentalFlag: AppContextTestRender['setExperimentalFlag'] = (flags) => {
ExperimentalFeaturesService.init({
experimentalFeatures: {
...allowedExperimentalValues,
...flags,
},
});
store.dispatch({
type: UpdateExperimentalFeaturesTestActionType,
payload: flags,

View file

@ -0,0 +1,155 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { ConsoleTestSetup, HelpSidePanelSelectorsAndActions } from '../mocks';
import {
getCommandListMock,
getConsoleTestSetup,
getHelpSidePanelSelectorsAndActionsMock,
} from '../mocks';
import React from 'react';
import { waitFor } from '@testing-library/react';
describe('When rendering the command list (help output)', () => {
let render: ConsoleTestSetup['renderConsole'];
let renderResult: ReturnType<typeof render>;
let consoleSelectors: ConsoleTestSetup['selectors'];
let enterCommand: ConsoleTestSetup['enterCommand'];
beforeEach(() => {
const testSetup = getConsoleTestSetup();
render = (props = {}) => (renderResult = testSetup.renderConsole(props));
consoleSelectors = testSetup.selectors;
enterCommand = testSetup.enterCommand;
});
describe('and its displayed on the side panel', () => {
let renderAndOpenHelpPanel: typeof render;
let helpPanelSelectors: HelpSidePanelSelectorsAndActions;
beforeEach(() => {
renderAndOpenHelpPanel = (props) => {
render(props);
helpPanelSelectors = getHelpSidePanelSelectorsAndActionsMock(renderResult);
consoleSelectors.openHelpPanel();
return renderResult;
};
});
it('should display the help panel header', () => {
renderAndOpenHelpPanel();
expect(renderResult.getByTestId('test-sidePanel-header')).toHaveTextContent(
'HelpUse the add () button to populate a response action to the text bar. Add ' +
'additional parameters or comments as necessary.'
);
});
it('should display the command list', () => {
renderAndOpenHelpPanel();
expect(renderResult.getByTestId('test-commandList')).toBeTruthy();
});
it('should close the side panel when close button is clicked', () => {
renderAndOpenHelpPanel();
consoleSelectors.closeHelpPanel();
expect(renderResult.queryByTestId('test-sidePanel')).toBeNull();
});
it('should display helpful tips', () => {
renderAndOpenHelpPanel();
expect(renderResult.getByTestId('test-commandList-helpfulTips')).toHaveTextContent(
'Helpful tips:You can enter consecutive response actions — no need to wait for previous ' +
'actions to complete.Leaving the response console does not terminate any actions that have ' +
'been submitted.Learn moreExternal link(opens in a new tab or window) about response actions ' +
'and using the console.'
);
expect(renderResult.getByTestId('test-commandList-helpfulHintDocLink')).toBeTruthy();
});
it('should display common commands and parameters section', () => {
renderAndOpenHelpPanel();
expect(
renderResult.getByTestId('test-commandList-Supportingcommandsparameters')
).toBeTruthy();
});
it('should group commands by group label', () => {
renderAndOpenHelpPanel();
const groups = helpPanelSelectors.getHelpGroupLabels();
expect(groups).toEqual([
'group 1',
'Supporting commands & parameters',
'group 2',
'Other commands',
]);
});
it('should display the list of command in the expected order', () => {
renderAndOpenHelpPanel();
const commands = helpPanelSelectors.getHelpCommandNames('group 1');
expect(commands).toEqual(['cmd6 --foo', 'cmd1']);
});
it('should hide command if command definition helpHidden is true', () => {
const commands = getCommandListMock();
commands[0].helpHidden = true;
renderAndOpenHelpPanel({ commands });
expect(renderResult.queryByTestId('test-commandList-group1-cmd1')).toBeNull();
});
it('should disable "add to text bar" button if command definition helpHidden is true', () => {
const commands = getCommandListMock();
commands[0].helpDisabled = true;
renderAndOpenHelpPanel({ commands });
expect(renderResult.getByTestId('test-commandList-group1-cmd1-addToInput')).toBeDisabled();
});
it('should add command to console input when [+] button is clicked', () => {
renderAndOpenHelpPanel();
renderResult.getByTestId('test-commandList-group1-cmd6-addToInput').click();
expect(consoleSelectors.getInputText()).toEqual('cmd6 --foo ');
});
it('should display custom help output when Command service has `getHelp()` defined', async () => {
const HelpComponent: React.FunctionComponent = () => {
return <div data-test-subj="custom-help">{'help output'}</div>;
};
render({ HelpComponent });
enterCommand('help');
await waitFor(() => {
expect(renderResult.getByTestId('custom-help')).toBeTruthy();
});
});
});
describe('And displayed when `help` command is entered', () => {
it('should display custom help output when Command service has `getHelp()` defined', async () => {
const HelpComponent: React.FunctionComponent = () => {
return <div data-test-subj="custom-help">{'help output'}</div>;
};
render({ HelpComponent });
enterCommand('help');
await waitFor(() => {
expect(renderResult.getByTestId('custom-help')).toBeTruthy();
});
});
});
});

View file

@ -7,7 +7,6 @@
import React, { memo, useMemo, useCallback } from 'react';
import styled from 'styled-components';
import { groupBy, sortBy } from 'lodash';
import {
EuiBadge,
EuiBasicTable,
@ -24,6 +23,7 @@ import {
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { sortBy } from 'lodash';
import type { CommandDefinition } from '../types';
import { useTestIdGenerator } from '../../../hooks/use_test_id_generator';
import { useDataTestSubj } from '../hooks/state_selectors/use_data_test_subj';
@ -33,6 +33,21 @@ import { getCommandNameWithArgs } from '../service/utils';
import { ConsoleCodeBlock } from './console_code_block';
import { useKibana } from '../../../../common/lib/kibana';
const otherCommandsGroupLabel = i18n.translate(
'xpack.securitySolution.console.commandList.otherCommandsGroup.label',
{
defaultMessage: 'Other commands',
}
);
/**
* Takes a string and removes all non-letters/number from it.
* @param value
*/
export const convertToTestId = (value: string): string => {
return value.replace(/[^A-Za-z0-9]/g, '');
};
// @ts-expect-error TS2769
const StyledEuiBasicTable = styled(EuiBasicTable)`
margin-top: ${({ theme: { eui } }) => eui.euiSizeS};
@ -78,12 +93,10 @@ export interface CommandListProps {
}
export const CommandList = memo<CommandListProps>(({ commands, display = 'default' }) => {
const getTestId = useTestIdGenerator(useDataTestSubj());
const getTestId = useTestIdGenerator(useDataTestSubj('commandList'));
const dispatch = useConsoleStateDispatch();
const { docLinks } = useKibana().services;
const allowedCommands = commands.filter((command) => command.helpHidden !== true);
const footerMessage = useMemo(() => {
return (
<EuiDescriptionList
@ -111,13 +124,6 @@ export const CommandList = memo<CommandListProps>(({ commands, display = 'defaul
);
}, []);
const otherCommandsGroupLabel = i18n.translate(
'xpack.securitySolution.console.commandList.otherCommandsGroup.label',
{
defaultMessage: 'Other commands',
}
);
const updateInputText = useCallback(
(text) => () => {
dispatch({
@ -136,23 +142,62 @@ export const CommandList = memo<CommandListProps>(({ commands, display = 'defaul
);
const commandsByGroups = useMemo(() => {
return Object.values(groupBy(allowedCommands, 'helpGroupLabel')).reduce<CommandDefinition[][]>(
(acc, current) => {
if (current[0].helpGroupPosition !== undefined) {
// If it already exists just move it to the end
if (acc[current[0].helpGroupPosition]) {
acc[acc.length] = acc[current[0].helpGroupPosition];
}
const helpGroups = new Map<
string,
{ label: string; position: number; list: CommandDefinition[] }
>();
acc[current[0].helpGroupPosition] = sortBy(current, 'helpCommandPosition');
} else if (current.length) {
acc.push(sortBy(current, 'helpCommandPosition'));
// We only show commands that are no hidden
const allowedCommands = commands.filter((command) => command.helpHidden !== true);
for (const allowedCommand of allowedCommands) {
const { helpGroupLabel = otherCommandsGroupLabel, helpGroupPosition = Infinity } =
allowedCommand;
const groupEntry = helpGroups.get(helpGroupLabel);
if (groupEntry) {
groupEntry.list.push(allowedCommand);
// Its possible (but probably not intentionally) that the same Group Label might
// have different positions defined (ex. one has a position, and another does not,
// which defaults to `Infinity`. If we detect that here, then update the group
// position. In the end, the group label will have the last explicitly defined
// position found.
if (
groupEntry.position === Infinity &&
helpGroupPosition !== undefined &&
helpGroupPosition !== groupEntry.position
) {
groupEntry.position = helpGroupPosition;
}
return acc;
},
[]
);
}, [allowedCommands]);
} else {
helpGroups.set(allowedCommand.helpGroupLabel as string, {
label: helpGroupLabel,
position: helpGroupPosition,
list: [allowedCommand],
});
}
}
// Sort by Group position and return an array of arrays with the list of commands per group
return sortBy(Array.from(helpGroups.values()), 'position').map((group) => {
// ensure all commands in this group have a `helpCommandPosition`. Those missing one, will
// be set to `Infinity` so that they are moved to the end.
const groupCommandList = group.list.map((command) => {
if (command.helpCommandPosition === undefined) {
return {
...command,
helpCommandPosition: Infinity,
};
}
return command;
});
return sortBy(groupCommandList, 'helpCommandPosition');
});
}, [commands]);
const getTableItems = useCallback(
(
@ -169,24 +214,35 @@ export const CommandList = memo<CommandListProps>(({ commands, display = 'defaul
[commandsByGroup[0]?.helpGroupLabel ?? otherCommandsGroupLabel]: command,
}));
},
[otherCommandsGroupLabel]
[]
);
const getTableColumns = useCallback(
(commandsByGroup) => {
const groupLabel = commandsByGroup[0]?.helpGroupLabel ?? otherCommandsGroupLabel;
const groupTestIdSuffix = convertToTestId(groupLabel);
return [
{
field: commandsByGroup[0]?.helpGroupLabel ?? otherCommandsGroupLabel,
name: commandsByGroup[0]?.helpGroupLabel ?? otherCommandsGroupLabel,
field: groupLabel,
name: <div data-test-subj={getTestId('group')}>{groupLabel}</div>,
render: (command: CommandDefinition) => {
const commandNameWithArgs = getCommandNameWithArgs(command);
return (
<StyledEuiFlexGroup alignItems="center">
<StyledEuiFlexGroup
alignItems="center"
data-test-subj={getTestId(`${groupTestIdSuffix}-${command.name}`)}
>
<EuiFlexItem grow={1}>
<EuiDescriptionList
data-test-subj={getTestId('command')}
listItems={[
{
title: <EuiBadge>{commandNameWithArgs}</EuiBadge>,
title: (
<EuiBadge data-test-subj={getTestId('commandName')}>
{commandNameWithArgs}
</EuiBadge>
),
description: (
<>
<EuiSpacer size="xs" />
@ -197,7 +253,6 @@ export const CommandList = memo<CommandListProps>(({ commands, display = 'defaul
),
},
]}
data-test-subj={getTestId('commandList-command')}
/>
</EuiFlexItem>
{command.helpGroupLabel !== HELP_GROUPS.supporting.label &&
@ -222,6 +277,9 @@ export const CommandList = memo<CommandListProps>(({ commands, display = 'defaul
aria-label={`updateTextInputCommand-${command.name}`}
onClick={updateInputText(`${commandNameWithArgs} `)}
isDisabled={command.helpDisabled === true}
data-test-subj={getTestId(
`${groupTestIdSuffix}-${command.name}-addToInput`
)}
/>
</EuiToolTip>
</EuiFlexItem>
@ -232,7 +290,7 @@ export const CommandList = memo<CommandListProps>(({ commands, display = 'defaul
},
];
},
[getTestId, otherCommandsGroupLabel, updateInputText]
[getTestId, updateInputText]
);
const getFilteredCommands = useCallback(
@ -258,7 +316,11 @@ export const CommandList = memo<CommandListProps>(({ commands, display = 'defaul
defaultMessage="{learnMore} about response actions and using the console."
values={{
learnMore: (
<EuiLink href={docLinks.links.securitySolution.responseActions} target="_blank">
<EuiLink
href={docLinks.links.securitySolution.responseActions}
target="_blank"
data-test-subj={getTestId('helpfulHintDocLink')}
>
<FormattedMessage
id="xpack.securitySolution.console.commandList.callout.readMoreLink"
defaultMessage="Learn more"
@ -277,6 +339,7 @@ export const CommandList = memo<CommandListProps>(({ commands, display = 'defaul
defaultMessage="Helpful tips:"
/>
}
data-test-subj={getTestId('helpfulTips')}
>
<ul>
{calloutItems.map((item, index) => (
@ -289,21 +352,24 @@ export const CommandList = memo<CommandListProps>(({ commands, display = 'defaul
);
return (
<>
<div data-test-subj={getTestId()}>
{commandsByGroups.map((commandsByGroup, i) => (
<StyledEuiBasicTable
data-test-subj={getTestId(
convertToTestId(commandsByGroup[0].helpGroupLabel ?? otherCommandsGroupLabel)
)}
key={`styledEuiBasicTable-${i}`}
items={getTableItems(commandsByGroup)}
columns={getTableColumns(commandsByGroup)}
/>
))}
{callout}
</>
</div>
);
}
return (
<>
<div data-test-subj={getTestId()}>
<EuiSpacer size="s" />
{commandsByGroups.map((commandsByGroup) => {
const groupLabel = commandsByGroup[0].helpGroupLabel;
@ -345,7 +411,7 @@ export const CommandList = memo<CommandListProps>(({ commands, display = 'defaul
),
},
]}
data-test-subj={getTestId('commandList-command')}
data-test-subj={getTestId('command')}
/>
</EuiFlexItem>
);
@ -355,7 +421,7 @@ export const CommandList = memo<CommandListProps>(({ commands, display = 'defaul
})}
<EuiSpacer size="xl" />
{footerMessage}
</>
</div>
);
});
CommandList.displayName = 'CommandList';

View file

@ -0,0 +1,50 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import type { ConsoleProps } from '..';
import type { AppContextTestRender } from '../../../../common/mock/endpoint';
import { getConsoleTestSetup } from '../mocks';
import { HELP_LABEL } from './console_header';
describe('Console header area', () => {
let render: (props?: Partial<ConsoleProps>) => ReturnType<AppContextTestRender['render']>;
let renderResult: ReturnType<typeof render>;
beforeEach(() => {
const testSetup = getConsoleTestSetup();
render = (props = {}) => (renderResult = testSetup.renderConsole(props));
});
it('should display the help button', () => {
render();
expect(renderResult.getByTestId('test-header-helpButton').textContent).toEqual(HELP_LABEL);
});
it('should not display a title component', () => {
render();
expect(renderResult.getByTestId('test-header-titleComponentContainer').textContent).toEqual('');
});
it('should show a title component if one was provided', () => {
render({ TitleComponent: () => <>{'header component here'}</> });
expect(renderResult.getByTestId('test-header-titleComponentContainer').textContent).toEqual(
'header component here'
);
});
it('should open the side panel when help button is clicked', () => {
render();
renderResult.getByTestId('test-header-helpButton').click();
expect(renderResult.getByTestId('test-sidePanel')).toBeTruthy();
});
});

View file

@ -10,11 +10,18 @@ import styled from 'styled-components';
import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
import { useDataTestSubj } from '../hooks/state_selectors/use_data_test_subj';
import { useTestIdGenerator } from '../../../hooks/use_test_id_generator';
import { useConsoleStateDispatch } from '../hooks/state_selectors/use_console_state_dispatch';
import { useWithSidePanel } from '../hooks/state_selectors/use_with_side_panel';
import type { ConsoleProps } from '..';
const HELP_LABEL = i18n.translate('xpack.securitySolution.console.layoutHeader.helpButtonLabel', {
export const HELP_LABEL = i18n.translate(
'xpack.securitySolution.console.layoutHeader.helpButtonTitle',
{ defaultMessage: 'Help' }
);
const HELP_TOOLTIP = i18n.translate('xpack.securitySolution.console.layoutHeader.helpButtonLabel', {
defaultMessage: 'Show help',
});
@ -28,6 +35,7 @@ export type ConsoleHeaderProps = Pick<ConsoleProps, 'TitleComponent'>;
export const ConsoleHeader = memo<ConsoleHeaderProps>(({ TitleComponent }) => {
const dispatch = useConsoleStateDispatch();
const panelCurrentlyShowing = useWithSidePanel().show;
const getTestId = useTestIdGenerator(useDataTestSubj('header'));
const isHelpOpen = panelCurrentlyShowing === 'help';
const handleHelpButtonOnClick = useCallback(() => {
@ -44,7 +52,11 @@ export const ConsoleHeader = memo<ConsoleHeaderProps>(({ TitleComponent }) => {
justifyContent="spaceBetween"
responsive={false}
>
<EuiFlexItem grow={1} className="eui-textTruncate noThemeOverrides">
<EuiFlexItem
grow={1}
className="eui-textTruncate noThemeOverrides"
data-test-subj={getTestId('titleComponentContainer')}
>
{TitleComponent ? <TitleComponent /> : ''}
</EuiFlexItem>
{!isHelpOpen && (
@ -53,9 +65,10 @@ export const ConsoleHeader = memo<ConsoleHeaderProps>(({ TitleComponent }) => {
style={{ marginLeft: 'auto' }}
onClick={handleHelpButtonOnClick}
iconType="help"
title={HELP_LABEL}
aria-label={HELP_LABEL}
title={HELP_TOOLTIP}
aria-label={HELP_TOOLTIP}
isSelected={isHelpOpen}
data-test-subj={getTestId('helpButton')}
>
<FormattedMessage
id="xpack.securitySolution.console.layoutHeader.helpButtonTitle"

View file

@ -25,29 +25,6 @@ describe('When a Console command is entered by the user', () => {
render = (props = {}) => (renderResult = testSetup.renderConsole(props));
});
it('should display all available commands when `help` command is entered', async () => {
render();
enterCommand('help');
expect(renderResult.getByTestId('test-helpOutput')).toBeTruthy();
await waitFor(() => {
expect(renderResult.getAllByTestId('test-commandList-command')).toHaveLength(commands.length);
});
});
it('should display custom help output when Command service has `getHelp()` defined', async () => {
const HelpComponent: React.FunctionComponent = () => {
return <div data-test-subj="custom-help">{'help output'}</div>;
};
render({ HelpComponent });
enterCommand('help');
await waitFor(() => {
expect(renderResult.getByTestId('custom-help')).toBeTruthy();
});
});
it('should clear the command output history when `clear` is entered', async () => {
render();
enterCommand('help');

View file

@ -9,6 +9,8 @@ import type { ReactNode } from 'react';
import React, { memo } from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiHorizontalRule } from '@elastic/eui';
import styled from 'styled-components';
import { useTestIdGenerator } from '../../../../hooks/use_test_id_generator';
import { useDataTestSubj } from '../../hooks/state_selectors/use_data_test_subj';
export interface SidePanelContentLayoutProps {
children: ReactNode;
@ -24,23 +26,30 @@ const StyledEuiFlexItemNoPadding = styled(EuiFlexItem)`
*/
export const SidePanelContentLayout = memo<SidePanelContentLayoutProps>(
({ headerContent, children }) => {
const getTestId = useTestIdGenerator(useDataTestSubj('sidePanel'));
return (
<EuiFlexGroup
direction="column"
responsive={false}
className="eui-fullHeight"
gutterSize="none"
data-test-subj={getTestId()}
>
{headerContent && (
<>
<EuiFlexItem grow={false} className="layout-container">
<EuiFlexItem
grow={false}
className="layout-container"
data-test-subj={getTestId('header')}
>
{headerContent}
</EuiFlexItem>
<EuiHorizontalRule margin="none" />
</>
)}
<StyledEuiFlexItemNoPadding className="eui-scrollBar eui-yScroll layout-container">
<div>{children}</div>
<div data-test-subj={getTestId('body')}>{children}</div>
</StyledEuiFlexItemNoPadding>
</EuiFlexGroup>
);

View file

@ -0,0 +1,49 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { ConsoleProps } from '../..';
import type { AppContextTestRender } from '../../../../../common/mock/endpoint';
import { getConsoleTestSetup } from '../../mocks';
import { act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
describe('When displaying the side panel', () => {
let render: (props?: Partial<ConsoleProps>) => ReturnType<AppContextTestRender['render']>;
let renderResult: ReturnType<typeof render>;
beforeEach(() => {
const testSetup = getConsoleTestSetup();
render = (props = {}) => {
renderResult = testSetup.renderConsole(props);
return renderResult;
};
});
describe('and displaying Help content', () => {
let renderAndOpenHelp: typeof render;
beforeEach(() => {
renderAndOpenHelp = (props) => {
render(props);
act(() => {
userEvent.click(renderResult.getByTestId('test-header-helpButton'));
});
expect(renderResult.getByTestId('test-sidePanel')).toBeTruthy();
return renderResult;
};
});
it('should display the help panel content', () => {
renderAndOpenHelp();
expect(renderResult.getByTestId('test-sidePanel-helpContent')).toBeTruthy();
});
});
});

View file

@ -19,11 +19,13 @@ import {
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { useTestIdGenerator } from '../../../../hooks/use_test_id_generator';
import { CommandList } from '../command_list';
import { useWithCommandList } from '../../hooks/state_selectors/use_with_command_list';
import { SidePanelContentLayout } from './side_panel_content_layout';
import { useWithSidePanel } from '../../hooks/state_selectors/use_with_side_panel';
import { useConsoleStateDispatch } from '../../hooks/state_selectors/use_console_state_dispatch';
import { useDataTestSubj } from '../../hooks/state_selectors/use_data_test_subj';
const StyledEuiFlexGroup = styled(EuiFlexGroup)`
padding-top: ${({ theme: { eui } }) => eui.euiPanelPaddingModifiers.paddingSmall};
@ -33,6 +35,7 @@ const StyledEuiFlexGroup = styled(EuiFlexGroup)`
export const SidePanelContentManager = memo(() => {
const dispatch = useConsoleStateDispatch();
const commands = useWithCommandList();
const getTestId = useTestIdGenerator(useDataTestSubj('sidePanel'));
const show = useWithSidePanel().show;
const closeHelpPanel = useCallback(() => {
@ -48,7 +51,7 @@ export const SidePanelContentManager = memo(() => {
<>
<StyledEuiFlexGroup>
<EuiFlexItem>
<EuiTitle size="s">
<EuiTitle size="s" data-test-subj={getTestId('headerTitle')}>
<h3>
<FormattedMessage
id="xpack.securitySolution.console.sidePanel.helpTitle"
@ -63,6 +66,7 @@ export const SidePanelContentManager = memo(() => {
iconType="cross"
color="text"
onClick={closeHelpPanel}
data-test-subj={getTestId('headerCloseButton')}
/>
</EuiFlexItem>
</StyledEuiFlexGroup>
@ -80,15 +84,19 @@ export const SidePanelContentManager = memo(() => {
);
}
return null;
}, [show, closeHelpPanel]);
}, [show, getTestId, closeHelpPanel]);
const panelBody: ReactNode = useMemo(() => {
if (show === 'help') {
return <CommandList commands={commands} display="table" />;
return (
<div data-test-subj={getTestId('helpContent')}>
<CommandList commands={commands} display="table" />
</div>
);
}
return null;
}, [commands, show]);
}, [commands, getTestId, show]);
if (!show) {
return null;

View file

@ -7,6 +7,16 @@
import { useConsoleStore } from '../../components/console_state/console_state';
export const useDataTestSubj = (): string | undefined => {
return useConsoleStore().state.dataTestSubj;
/**
* Returns the `data-test-subj` that was defined when the `Console` was rendered.
* Can optionally set a suffix on that value if one is provided
*/
export const useDataTestSubj = (suffix: string = ''): string => {
const dataTestSubj = useConsoleStore().state.dataTestSubj;
if (!dataTestSubj) {
return '';
}
return dataTestSubj + (suffix ? `-${suffix}` : '');
};

View file

@ -11,6 +11,8 @@ import React, { memo, useEffect } from 'react';
import { EuiCode } from '@elastic/eui';
import userEvent from '@testing-library/user-event';
import { act } from '@testing-library/react';
import { within } from '@testing-library/dom';
import { convertToTestId } from './components/command_list';
import { Console } from './console';
import type {
CommandArgumentValueSelectorProps,
@ -21,7 +23,19 @@ import type {
import type { AppContextTestRender } from '../../../common/mock/endpoint';
import { createAppRootMockRenderer } from '../../../common/mock/endpoint';
export interface ConsoleTestSetup {
interface ConsoleSelectorsAndActionsMock {
getLeftOfCursorInputText: () => string;
getRightOfCursorInputText: () => string;
getInputText: () => string;
openHelpPanel: () => void;
closeHelpPanel: () => void;
}
export interface ConsoleTestSetup
extends Pick<
AppContextTestRender,
'startServices' | 'coreStart' | 'depsStart' | 'queryClient' | 'history' | 'setExperimentalFlag'
> {
renderConsole(props?: Partial<ConsoleProps>): ReturnType<AppContextTestRender['render']>;
commands: CommandDefinition[];
@ -38,8 +52,54 @@ export interface ConsoleTestSetup {
useKeyboard: boolean;
}>
): void;
selectors: ConsoleSelectorsAndActionsMock;
}
/**
* A set of jest selectors and actions for interacting with the console
* @param dataTestSubj
*/
export const getConsoleSelectorsAndActionMock = (
renderResult: ReturnType<AppContextTestRender['render']>,
dataTestSubj: string = 'test'
): ConsoleTestSetup['selectors'] => {
const getLeftOfCursorInputText: ConsoleSelectorsAndActionsMock['getLeftOfCursorInputText'] =
() => {
return renderResult.getByTestId(`${dataTestSubj}-cmdInput-leftOfCursor`).textContent ?? '';
};
const getRightOfCursorInputText: ConsoleSelectorsAndActionsMock['getRightOfCursorInputText'] =
() => {
return renderResult.getByTestId(`${dataTestSubj}-cmdInput-rightOfCursor`).textContent ?? '';
};
const getInputText: ConsoleSelectorsAndActionsMock['getInputText'] = () => {
return getLeftOfCursorInputText() + getRightOfCursorInputText();
};
const isHelpPanelOpen = (): boolean => {
return Boolean(renderResult.queryByTestId(`${dataTestSubj}-sidePanel-helpContent`));
};
const openHelpPanel: ConsoleSelectorsAndActionsMock['openHelpPanel'] = () => {
if (!isHelpPanelOpen()) {
renderResult.getByTestId(`${dataTestSubj}-header-helpButton`).click();
}
};
const closeHelpPanel: ConsoleSelectorsAndActionsMock['closeHelpPanel'] = () => {
if (isHelpPanelOpen()) {
renderResult.getByTestId(`${dataTestSubj}-sidePanel-headerCloseButton`).click();
}
};
return {
getInputText,
getLeftOfCursorInputText,
getRightOfCursorInputText,
openHelpPanel,
closeHelpPanel,
};
};
/**
* Finds the console in the Render Result and enters the command provided
* @param renderResult
@ -75,17 +135,23 @@ export const enterConsoleCommand = (
export const getConsoleTestSetup = (): ConsoleTestSetup => {
const mockedContext = createAppRootMockRenderer();
const { startServices, coreStart, depsStart, queryClient, history, setExperimentalFlag } =
mockedContext;
let renderResult: ReturnType<AppContextTestRender['render']>;
const commandList = getCommandListMock();
let testSubj: string;
const renderConsole: ConsoleTestSetup['renderConsole'] = ({
prompt = '$$>',
commands = commandList,
'data-test-subj': dataTestSubj = 'test',
...others
} = {}) => {
testSubj = dataTestSubj;
return (renderResult = mockedContext.render(
<Console prompt={prompt} commands={commands} data-test-subj={dataTestSubj} {...others} />
));
@ -95,10 +161,52 @@ export const getConsoleTestSetup = (): ConsoleTestSetup => {
enterConsoleCommand(renderResult, cmd, options);
};
let selectors: ConsoleSelectorsAndActionsMock;
const initSelectorsIfNeeded = () => {
if (selectors) {
return selectors;
}
if (!testSubj) {
throw new Error(`no 'dataTestSubj' provided to 'render()'!`);
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
selectors = getConsoleSelectorsAndActionMock(renderResult, testSubj!);
};
return {
startServices,
coreStart,
depsStart,
queryClient,
history,
setExperimentalFlag,
renderConsole,
commands: commandList,
enterCommand,
selectors: {
getInputText: () => {
initSelectorsIfNeeded();
return selectors.getInputText();
},
getLeftOfCursorInputText: () => {
initSelectorsIfNeeded();
return selectors.getLeftOfCursorInputText();
},
getRightOfCursorInputText: () => {
initSelectorsIfNeeded();
return selectors.getRightOfCursorInputText();
},
openHelpPanel: () => {
initSelectorsIfNeeded();
return selectors.openHelpPanel();
},
closeHelpPanel: () => {
initSelectorsIfNeeded();
return selectors.closeHelpPanel();
},
},
};
};
@ -142,11 +250,13 @@ export const getCommandListMock = (): CommandDefinition[] => {
name: 'cmd1',
about: 'a command with no options',
RenderComponent: jest.fn(RenderComponent),
helpGroupLabel: 'group 1',
},
{
name: 'cmd2',
about: 'runs cmd 2',
RenderComponent: jest.fn(RenderComponent),
helpGroupLabel: 'group 2',
args: {
file: {
about: 'Includes file in the run',
@ -173,6 +283,7 @@ export const getCommandListMock = (): CommandDefinition[] => {
name: 'cmd3',
about: 'allows argument to be used multiple times',
RenderComponent: jest.fn(RenderComponent),
helpGroupPosition: 0,
args: {
foo: {
about: 'foo stuff',
@ -186,6 +297,7 @@ export const getCommandListMock = (): CommandDefinition[] => {
about: 'all options optional, but at least one is required',
RenderComponent: jest.fn(RenderComponent),
mustHaveArgs: true,
helpGroupPosition: 1,
args: {
foo: {
about: 'foo stuff',
@ -226,6 +338,9 @@ export const getCommandListMock = (): CommandDefinition[] => {
mustHaveArgs: true,
exampleUsage: 'cmd6 --foo 123',
exampleInstruction: 'Enter --foo to execute',
helpGroupLabel: 'group 1',
helpGroupPosition: 0,
helpCommandPosition: 0,
args: {
foo: {
about: 'foo stuff',
@ -245,6 +360,7 @@ export const getCommandListMock = (): CommandDefinition[] => {
name: 'cmd7',
about: 'Command with argument selector',
RenderComponent: jest.fn(RenderComponent),
helpGroupLabel: 'group 2',
args: {
foo: {
about: 'foo stuff',
@ -273,3 +389,46 @@ export const ArgumentSelectorComponentMock = memo<
);
});
ArgumentSelectorComponentMock.displayName = 'ArgumentSelectorComponentMock';
export interface HelpSidePanelSelectorsAndActions {
getHelpGroupLabels: () => string[];
getHelpCommandNames: (forGroup?: string) => string[];
}
export const getHelpSidePanelSelectorsAndActionsMock = (
renderResult: ReturnType<AppContextTestRender['render']>,
dataTestSubj: string = 'test'
): HelpSidePanelSelectorsAndActions => {
const getHelpGroupLabels: HelpSidePanelSelectorsAndActions['getHelpGroupLabels'] = () => {
// FYI: we're collapsing the labels here because EUI includes mobile elements
// in the DOM that have the same test ids
return Array.from(
new Set(
renderResult
.getAllByTestId(`${dataTestSubj}-commandList-group`)
.map((element) => element.textContent ?? '')
)
);
};
const getHelpCommandNames: HelpSidePanelSelectorsAndActions['getHelpCommandNames'] = (
forGroup
) => {
let searchContainer = renderResult.container;
if (forGroup) {
searchContainer = renderResult.getByTestId(
`${dataTestSubj}-commandList-${convertToTestId(forGroup)}`
);
}
return within(searchContainer)
.getAllByTestId(`${dataTestSubj}-commandList-commandName`)
.map((commandEle) => commandEle.textContent ?? '');
};
return {
getHelpGroupLabels,
getHelpCommandNames,
};
};

View file

@ -118,7 +118,7 @@ const capabilitiesAndPrivilegesValidator = (command: Command): true | string =>
return true;
};
const HELP_GROUPS = Object.freeze({
export const HELP_GROUPS = Object.freeze({
responseActions: {
position: 0,
label: i18n.translate('xpack.securitySolution.endpointConsoleCommands.groups.responseActions', {

View file

@ -0,0 +1,75 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { ConsoleTestSetup, HelpSidePanelSelectorsAndActions } from '../../../console/mocks';
import {
getConsoleTestSetup,
getHelpSidePanelSelectorsAndActionsMock,
} from '../../../console/mocks';
import { getEndpointConsoleCommands } from '../..';
import { EndpointMetadataGenerator } from '../../../../../../common/endpoint/data_generators/endpoint_metadata_generator';
import { getEndpointPrivilegesInitialStateMock } from '../../../../../common/components/user_privileges/endpoint/mocks';
import { sortBy } from 'lodash';
import { HELP_GROUPS } from '../console_commands_definition';
describe('When displaying Endpoint Response Actions', () => {
let render: ConsoleTestSetup['renderConsole'];
let renderResult: ReturnType<typeof render>;
let consoleSelectors: ConsoleTestSetup['selectors'];
let helpPanelSelectors: HelpSidePanelSelectorsAndActions;
beforeEach(() => {
const testSetup = getConsoleTestSetup();
testSetup.setExperimentalFlag({
responseActionGetFileEnabled: true,
responseActionExecuteEnabled: true,
});
const endpointMetadata = new EndpointMetadataGenerator().generate();
const commands = getEndpointConsoleCommands({
endpointAgentId: '123',
endpointCapabilities: endpointMetadata.Endpoint.capabilities ?? [],
endpointPrivileges: getEndpointPrivilegesInitialStateMock(),
});
consoleSelectors = testSetup.selectors;
render = (props = { commands }) => {
renderResult = testSetup.renderConsole(props);
helpPanelSelectors = getHelpSidePanelSelectorsAndActionsMock(renderResult);
return renderResult;
};
});
it('should display expected help groups', () => {
render();
consoleSelectors.openHelpPanel();
expect(helpPanelSelectors.getHelpGroupLabels()).toEqual([
...sortBy(Object.values(HELP_GROUPS), 'position').map((group) => group.label),
'Supporting commands & parameters',
]);
});
it('should display response action commands in the help panel in expected order', () => {
render();
consoleSelectors.openHelpPanel();
const commands = helpPanelSelectors.getHelpCommandNames(HELP_GROUPS.responseActions.label);
expect(commands).toEqual([
'isolate',
'release',
'status',
'processes',
'kill-process --pid',
'suspend-process --pid',
'get-file --path',
'execute --command',
]);
});
});

View file

@ -16,6 +16,7 @@ import { useCallback } from 'react';
* @example
* // `props['data-test-subj'] = 'abc';
* const getTestId = useTestIdGenerator(props['data-test-subj']);
* getTestId(); // abc
* getTestId('body'); // abc-body
* getTestId('some-other-ui-section'); // abc-some-other-ui-section
*
@ -24,11 +25,11 @@ import { useCallback } from 'react';
* const getTestId = useTestIdGenerator(props['data-test-subj']);
* getTestId('body'); // undefined
*/
export const useTestIdGenerator = (prefix?: string): ((suffix: string) => string | undefined) => {
export const useTestIdGenerator = (prefix?: string): ((suffix?: string) => string | undefined) => {
return useCallback(
(suffix: string): string | undefined => {
(suffix: string = ''): string | undefined => {
if (prefix) {
return `${prefix}-${suffix}`;
return `${prefix}${suffix ? `-${suffix}` : ''}`;
}
},
[prefix]