[8.15] [Integration Assistant] Implement unit tests for the UI (#187590) (#188114)

# Backport

This will backport the following commits from `main` to `8.15`:
- [[Integration Assistant] Implement unit tests for the UI
(#187590)](https://github.com/elastic/kibana/pull/187590)

<!--- Backport version: 9.4.3 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sqren/backport)

<!--BACKPORT [{"author":{"name":"Sergi
Massaneda","email":"sergi.massaneda@elastic.co"},"sourceCommit":{"committedDate":"2024-07-11T14:43:31Z","message":"[Integration
Assistant] Implement unit tests for the UI (#187590)\n\n##
Summary\r\n\r\nUnit tests for the Integration Assistant UI
components\r\n\r\n---------\r\n\r\nCo-authored-by: Elastic Machine
<elasticmachine@users.noreply.github.com>","sha":"490bbdb3f61ab0435befc1bc303b40a7774a4532","branchLabelMapping":{"^v8.16.0$":"main","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:skip","Team:
SecuritySolution","v8.15.0","v8.16.0"],"title":"[Integration Assistant]
Implement unit tests for the
UI","number":187590,"url":"https://github.com/elastic/kibana/pull/187590","mergeCommit":{"message":"[Integration
Assistant] Implement unit tests for the UI (#187590)\n\n##
Summary\r\n\r\nUnit tests for the Integration Assistant UI
components\r\n\r\n---------\r\n\r\nCo-authored-by: Elastic Machine
<elasticmachine@users.noreply.github.com>","sha":"490bbdb3f61ab0435befc1bc303b40a7774a4532"}},"sourceBranch":"main","suggestedTargetBranches":["8.15"],"targetPullRequestStates":[{"branch":"8.15","label":"v8.15.0","branchLabelMappingKey":"^v(\\d+).(\\d+).\\d+$","isSourceBranch":false,"state":"NOT_CREATED"},{"branch":"main","label":"v8.16.0","branchLabelMappingKey":"^v8.16.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/187590","number":187590,"mergeCommit":{"message":"[Integration
Assistant] Implement unit tests for the UI (#187590)\n\n##
Summary\r\n\r\nUnit tests for the Integration Assistant UI
components\r\n\r\n---------\r\n\r\nCo-authored-by: Elastic Machine
<elasticmachine@users.noreply.github.com>","sha":"490bbdb3f61ab0435befc1bc303b40a7774a4532"}}]}]
BACKPORT-->

Co-authored-by: Sergi Massaneda <sergi.massaneda@elastic.co>
This commit is contained in:
Kibana Machine 2024-07-11 18:44:46 +02:00 committed by GitHub
parent 2dc9f66822
commit bb3c191384
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 2747 additions and 148 deletions

View file

@ -25,7 +25,11 @@ export const AuthorizationWrapper = React.memo<AuthorizationWrapperProps>(
if (!isAuthorized) {
return (
<EuiCallOut title={i18n.PRIVILEGES_MISSING_TITLE} iconType="iInCircle">
<EuiCallOut
title={i18n.PRIVILEGES_MISSING_TITLE}
iconType="iInCircle"
data-test-subj="missingPrivilegesCallOut"
>
<MissingPrivilegesDescription {...authRequired} />
</EuiCallOut>
);

View file

@ -44,7 +44,12 @@ export const ButtonsFooter = React.memo<ButtonsFooterProps>(
const integrationsUrl = useKibana().services.application.getUrlForApp('integrations');
return (
<KibanaPageTemplate.BottomBar paddingSize="s" position="sticky" css={bottomBarCss}>
<EuiFlexGroup direction="column" alignItems="center" css={containerCss}>
<EuiFlexGroup
direction="column"
alignItems="center"
css={containerCss}
data-test-subj="buttonsFooter"
>
<EuiFlexItem css={contentCss}>
<EuiFlexGroup
direction="row"
@ -54,7 +59,11 @@ export const ButtonsFooter = React.memo<ButtonsFooterProps>(
>
<EuiFlexItem>
{!hideCancel && (
<EuiLink href={integrationsUrl} color="text">
<EuiLink
href={integrationsUrl}
color="text"
data-test-subj="buttonsFooter-cancelButton"
>
{cancelButtonText || (
<FormattedMessage
id="xpack.integrationAssistant.footer.cancel"
@ -73,7 +82,11 @@ export const ButtonsFooter = React.memo<ButtonsFooterProps>(
>
<EuiFlexItem grow={false}>
{onBack && (
<EuiLink onClick={onBack} color="text">
<EuiLink
onClick={onBack}
color="text"
data-test-subj="buttonsFooter-backButton"
>
{backButtonText || (
<FormattedMessage
id="xpack.integrationAssistant.footer.back"
@ -85,7 +98,13 @@ export const ButtonsFooter = React.memo<ButtonsFooterProps>(
</EuiFlexItem>
<EuiFlexItem grow={false}>
{onNext && (
<EuiButton fill color="primary" onClick={onNext} isDisabled={isNextDisabled}>
<EuiButton
fill
color="primary"
onClick={onNext}
isDisabled={isNextDisabled}
data-test-subj="buttonsFooter-nextButton"
>
{nextButtonText || (
<FormattedMessage
id="xpack.integrationAssistant.footer.next"

View file

@ -0,0 +1,23 @@
/*
* 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 { Authorization, RoutesAuthorization } from '../use_authorization';
export const useAuthorization = jest.fn(
(): Authorization => ({
canCreateIntegrations: true,
canExecuteConnectors: true,
canCreateConnectors: true,
})
);
export const useRoutesAuthorization = jest.fn(
(): RoutesAuthorization => ({
canUseIntegrationAssistant: true,
canUseIntegrationUpload: true,
})
);

View file

@ -0,0 +1,14 @@
/*
* 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 { Availability } from '../use_availability';
export const useAvailability = jest.fn((): Availability => {
return { hasLicense: true, renderUpselling: undefined };
});
export const useIsAvailable = jest.fn((): boolean => true);

View file

@ -11,10 +11,12 @@ import { MINIMUM_LICENSE_TYPE } from '../../../common/constants';
import { useKibana } from './use_kibana';
import type { RenderUpselling } from '../../services';
export const useAvailability = (): {
export interface Availability {
hasLicense: boolean;
renderUpselling: RenderUpselling | undefined;
} => {
}
export const useAvailability = (): Availability => {
const { licensing, renderUpselling$ } = useKibana().services;
const licenseService = useObservable(licensing.license$);
const renderUpselling = useObservable(renderUpselling$);

View file

@ -0,0 +1,35 @@
/*
* 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 { EpmPackageResponse } from './api';
import { getIntegrationNameFromResponse } from './api_parsers';
describe('getIntegrationNameFromResponse', () => {
it.each([
['audit-security.data-stream-1.0.0', 'security-1.0.0'],
['audit-endpoint_security.data_stream-1.0.0', 'endpoint_security-1.0.0'],
['audit-endpoint_security_2.data_stream-1.0.0', 'endpoint_security_2-1.0.0'],
])(
'should return the integration name from the ingest pipeline name %s',
(ingestPipelineName, expected) => {
const response = { response: [{ id: ingestPipelineName }] } as EpmPackageResponse;
expect(getIntegrationNameFromResponse(response)).toEqual(expected);
}
);
it('should return an empty string if the response is empty', () => {
const response = { response: [] } as unknown as EpmPackageResponse;
expect(getIntegrationNameFromResponse(response)).toEqual('');
});
it('should return an empty string if the response is undefined', () => {
const response = {} as EpmPackageResponse;
expect(getIntegrationNameFromResponse(response)).toEqual('');
});
it('should return an empty string if the response is null', () => {
const response = { response: null } as unknown as EpmPackageResponse;
expect(getIntegrationNameFromResponse(response)).toEqual('');
});
});

View file

@ -0,0 +1,174 @@
/*
* 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 { PropsWithChildren } from 'react';
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { render } from '@testing-library/react';
import { TestProvider } from '../../mocks/test_provider';
import { CreateIntegration } from './create_integration';
import { mockServices } from '../../services/mocks/services';
import { useRoutesAuthorization } from '../../common/hooks/use_authorization';
import { useIsAvailable } from '../../common/hooks/use_availability';
jest.mock('../../common/hooks/use_authorization');
jest.mock('../../common/hooks/use_availability');
const mockUseRoutesAuthorization = useRoutesAuthorization as jest.Mock;
const mockUseIsAvailable = useIsAvailable as jest.Mock;
jest.mock('./create_integration_landing', () => ({
CreateIntegrationLanding: jest.fn(() => <div data-test-subj="landingMock" />),
}));
jest.mock('./create_integration_upload', () => ({
CreateIntegrationUpload: jest.fn(() => <div data-test-subj="uploadMock" />),
}));
jest.mock('./create_integration_assistant', () => ({
CreateIntegrationAssistant: jest.fn(() => <div data-test-subj="assistantMock" />),
}));
const getWrapper = (pathname: string): React.FC<PropsWithChildren<{}>> =>
function wrapper({ children }) {
return (
<TestProvider>
<MemoryRouter initialEntries={[{ pathname }]}>{children}</MemoryRouter>
</TestProvider>
);
};
const getElement = () => <CreateIntegration services={mockServices} />;
describe('CreateIntegration', () => {
describe('when url is /create', () => {
let wrapper: React.ComponentType;
beforeEach(() => {
wrapper = getWrapper('/create');
});
it('should render the landing page', () => {
const result = render(getElement(), { wrapper });
expect(result.queryByTestId('landingMock')).toBeInTheDocument();
});
describe('and user is not authorized', () => {
beforeEach(() => {
mockUseRoutesAuthorization.mockReturnValueOnce({
canUseIntegrationAssistant: false,
canUseIntegrationUpload: false,
});
});
it('should render the landing page', () => {
const result = render(getElement(), { wrapper });
expect(result.queryByTestId('landingMock')).toBeInTheDocument();
});
});
describe('and the product is not available', () => {
beforeEach(() => {
mockUseIsAvailable.mockReturnValueOnce(false);
});
it('should render the landing page', () => {
const result = render(getElement(), { wrapper });
expect(result.queryByTestId('landingMock')).toBeInTheDocument();
});
});
});
describe('when url is /create/assistant', () => {
let wrapper: React.ComponentType;
beforeEach(() => {
wrapper = getWrapper('/create/assistant');
});
it('should render the assistant page', () => {
const result = render(getElement(), { wrapper });
expect(result.queryByTestId('assistantMock')).toBeInTheDocument();
});
describe('and user is not authorized', () => {
beforeEach(() => {
mockUseRoutesAuthorization.mockReturnValueOnce({
canUseIntegrationAssistant: false,
canUseIntegrationUpload: true,
});
});
it('should render the landing page', () => {
const result = render(getElement(), { wrapper });
expect(result.queryByTestId('assistantMock')).not.toBeInTheDocument();
expect(result.queryByTestId('landingMock')).toBeInTheDocument();
});
});
describe('and the product is not available', () => {
beforeEach(() => {
mockUseIsAvailable.mockReturnValueOnce(false);
});
it('should render the landing page', () => {
const result = render(getElement(), { wrapper });
expect(result.queryByTestId('assistantMock')).not.toBeInTheDocument();
expect(result.queryByTestId('landingMock')).toBeInTheDocument();
});
});
});
describe('when url is /create/upload', () => {
let wrapper: React.ComponentType;
beforeEach(() => {
wrapper = getWrapper('/create/upload');
});
it('should render the upload page', () => {
const result = render(getElement(), { wrapper });
expect(result.queryByTestId('uploadMock')).toBeInTheDocument();
});
describe('and user is not authorized', () => {
beforeEach(() => {
mockUseRoutesAuthorization.mockReturnValueOnce({
canUseIntegrationAssistant: true,
canUseIntegrationUpload: false,
});
});
it('should render the landing page', () => {
const result = render(getElement(), { wrapper });
expect(result.queryByTestId('uploadMock')).not.toBeInTheDocument();
expect(result.queryByTestId('landingMock')).toBeInTheDocument();
});
});
describe('and the product is not available', () => {
beforeEach(() => {
mockUseIsAvailable.mockReturnValueOnce(false);
});
it('should render the landing page', () => {
const result = render(getElement(), { wrapper });
expect(result.queryByTestId('uploadMock')).not.toBeInTheDocument();
expect(result.queryByTestId('landingMock')).toBeInTheDocument();
});
});
});
describe('when url is not exact', () => {
let wrapper: React.ComponentType;
beforeEach(() => {
wrapper = getWrapper('/create/something_else');
});
it('should render the landing page', () => {
const result = render(getElement(), { wrapper });
expect(result.queryByTestId('landingMock')).toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,140 @@
/*
* 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 { render } from '@testing-library/react';
import { TestProvider } from '../../../mocks/test_provider';
import { CreateIntegrationAssistant } from './create_integration_assistant';
import type { State } from './state';
export const defaultInitialState: State = {
step: 1,
connector: undefined,
integrationSettings: undefined,
isGenerating: false,
result: undefined,
};
const mockInitialState = jest.fn((): State => defaultInitialState);
jest.mock('./state', () => ({
...jest.requireActual('./state'),
get initialState() {
return mockInitialState();
},
}));
const mockConnectorStep = jest.fn(() => <div data-test-subj="connectorStepMock" />);
const mockIntegrationStep = jest.fn(() => <div data-test-subj="integrationStepMock" />);
const mockDataStreamStep = jest.fn(() => <div data-test-subj="dataStreamStepMock" />);
const mockReviewStep = jest.fn(() => <div data-test-subj="reviewStepMock" />);
const mockDeployStep = jest.fn(() => <div data-test-subj="deployStepMock" />);
const mockIsConnectorStepReady = jest.fn();
const mockIsIntegrationStepReady = jest.fn();
const mockIsDataStreamStepReady = jest.fn();
const mockIsReviewStepReady = jest.fn();
jest.mock('./steps/connector_step', () => ({
ConnectorStep: () => mockConnectorStep(),
isConnectorStepReady: () => mockIsConnectorStepReady(),
}));
jest.mock('./steps/integration_step', () => ({
IntegrationStep: () => mockIntegrationStep(),
isIntegrationStepReady: () => mockIsIntegrationStepReady(),
}));
jest.mock('./steps/data_stream_step', () => ({
DataStreamStep: () => mockDataStreamStep(),
isDataStreamStepReady: () => mockIsDataStreamStepReady(),
}));
jest.mock('./steps/review_step', () => ({
ReviewStep: () => mockReviewStep(),
isReviewStepReady: () => mockIsReviewStepReady(),
}));
jest.mock('./steps/deploy_step', () => ({ DeployStep: () => mockDeployStep() }));
const renderIntegrationAssistant = () =>
render(<CreateIntegrationAssistant />, { wrapper: TestProvider });
describe('CreateIntegration', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('when step is 1', () => {
beforeEach(() => {
mockInitialState.mockReturnValueOnce({ ...defaultInitialState, step: 1 });
});
it('should render connector', () => {
const result = renderIntegrationAssistant();
expect(result.queryByTestId('connectorStepMock')).toBeInTheDocument();
});
it('should call isConnectorStepReady', () => {
renderIntegrationAssistant();
expect(mockIsConnectorStepReady).toHaveBeenCalled();
});
});
describe('when step is 2', () => {
beforeEach(() => {
mockInitialState.mockReturnValueOnce({ ...defaultInitialState, step: 2 });
});
it('should render integration', () => {
const result = renderIntegrationAssistant();
expect(result.queryByTestId('integrationStepMock')).toBeInTheDocument();
});
it('should call isIntegrationStepReady', () => {
renderIntegrationAssistant();
expect(mockIsIntegrationStepReady).toHaveBeenCalled();
});
});
describe('when step is 3', () => {
beforeEach(() => {
mockInitialState.mockReturnValueOnce({ ...defaultInitialState, step: 3 });
});
it('should render data stream', () => {
const result = renderIntegrationAssistant();
expect(result.queryByTestId('dataStreamStepMock')).toBeInTheDocument();
});
it('should call isDataStreamStepReady', () => {
renderIntegrationAssistant();
expect(mockIsDataStreamStepReady).toHaveBeenCalled();
});
});
describe('when step is 4', () => {
beforeEach(() => {
mockInitialState.mockReturnValueOnce({ ...defaultInitialState, step: 4 });
});
it('should render review', () => {
const result = renderIntegrationAssistant();
expect(result.queryByTestId('reviewStepMock')).toBeInTheDocument();
});
it('should call isReviewStepReady', () => {
renderIntegrationAssistant();
expect(mockIsReviewStepReady).toHaveBeenCalled();
});
});
describe('when step is 5', () => {
beforeEach(() => {
mockInitialState.mockReturnValueOnce({ ...defaultInitialState, step: 5 });
});
it('should render deploy', () => {
const result = renderIntegrationAssistant();
expect(result.queryByTestId('deployStepMock')).toBeInTheDocument();
});
});
});

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React, { useReducer, useMemo, useCallback, useEffect } from 'react';
import React, { useReducer, useMemo, useEffect } from 'react';
import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template';
import { Header } from './header';
import { Footer } from './footer';
@ -59,8 +59,6 @@ export const CreateIntegrationAssistant = React.memo(() => {
return false;
}, [state]);
const onGenerate = useCallback(() => actions.setIsGenerating(true), [actions]);
return (
<ActionsProvider value={actions}>
<KibanaPageTemplate>
@ -92,7 +90,6 @@ export const CreateIntegrationAssistant = React.memo(() => {
</KibanaPageTemplate.Section>
<Footer
currentStep={state.step}
onGenerate={onGenerate}
isGenerating={state.isGenerating}
isNextStepEnabled={isNextStepEnabled}
/>

View file

@ -0,0 +1,268 @@
/*
* 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 { render, act, type RenderResult } from '@testing-library/react';
import { TestProvider } from '../../../../mocks/test_provider';
import { Footer } from './footer';
import { ActionsProvider } from '../state';
import { mockActions } from '../mocks/state';
import { mockReportEvent } from '../../../../services/telemetry/mocks/service';
import { TelemetryEventType } from '../../../../services/telemetry/types';
const mockNavigate = jest.fn();
jest.mock('../../../../common/hooks/use_navigate', () => ({
...jest.requireActual('../../../../common/hooks/use_navigate'),
useNavigate: () => mockNavigate,
}));
const wrapper: React.FC = ({ children }) => (
<TestProvider>
<ActionsProvider value={mockActions}>{children}</ActionsProvider>
</TestProvider>
);
describe('Footer', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('when rendered', () => {
let result: RenderResult;
beforeEach(() => {
result = render(<Footer currentStep={1} isGenerating={false} isNextStepEnabled />, {
wrapper,
});
});
it('should render footer buttons component', () => {
expect(result.queryByTestId('buttonsFooter')).toBeInTheDocument();
});
it('should render cancel button', () => {
expect(result.queryByTestId('buttonsFooter-cancelButton')).toBeInTheDocument();
});
it('should render back button', () => {
expect(result.queryByTestId('buttonsFooter-backButton')).toBeInTheDocument();
});
it('should render next button', () => {
expect(result.queryByTestId('buttonsFooter-nextButton')).toBeInTheDocument();
});
});
describe('when step is 1', () => {
let result: RenderResult;
beforeEach(() => {
result = render(<Footer currentStep={1} isGenerating={false} isNextStepEnabled />, {
wrapper,
});
});
describe('when next button is clicked', () => {
beforeEach(() => {
act(() => {
result.getByTestId('buttonsFooter-nextButton').click();
});
});
it('should set step 2', () => {
expect(mockActions.setStep).toHaveBeenCalledWith(2);
});
it('should report telemetry', () => {
expect(mockReportEvent).toHaveBeenCalledWith(
TelemetryEventType.IntegrationAssistantStepComplete,
{
sessionId: expect.any(String),
step: 1,
stepName: 'Connector Step',
durationMs: expect.any(Number),
sessionElapsedTime: expect.any(Number),
}
);
});
});
describe('when back button is clicked', () => {
beforeEach(() => {
act(() => {
result.getByTestId('buttonsFooter-backButton').click();
});
});
it('should navigate to landing', () => {
expect(mockNavigate).toHaveBeenCalledWith('landing');
});
});
});
describe('when step is 2', () => {
let result: RenderResult;
beforeEach(() => {
result = render(<Footer currentStep={2} isGenerating={false} isNextStepEnabled />, {
wrapper,
});
});
describe('when next button is clicked', () => {
beforeEach(() => {
act(() => {
result.getByTestId('buttonsFooter-nextButton').click();
});
});
it('should set step 3', () => {
expect(mockActions.setStep).toHaveBeenCalledWith(3);
});
it('should report telemetry', () => {
expect(mockReportEvent).toHaveBeenCalledWith(
TelemetryEventType.IntegrationAssistantStepComplete,
{
sessionId: expect.any(String),
step: 2,
stepName: 'Integration Step',
durationMs: expect.any(Number),
sessionElapsedTime: expect.any(Number),
}
);
});
});
describe('when back button is clicked', () => {
beforeEach(() => {
act(() => {
result.getByTestId('buttonsFooter-backButton').click();
});
});
it('should set step 1', () => {
expect(mockActions.setStep).toHaveBeenCalledWith(1);
});
});
});
describe('when step is 3', () => {
describe('when it is not generating', () => {
let result: RenderResult;
beforeEach(() => {
result = render(<Footer currentStep={3} isGenerating={false} isNextStepEnabled />, {
wrapper,
});
});
describe('when next button is clicked', () => {
beforeEach(() => {
act(() => {
result.getByTestId('buttonsFooter-nextButton').click();
});
});
it('should set step 4', () => {
expect(mockActions.setIsGenerating).toHaveBeenCalledWith(true);
});
it('should report telemetry', () => {
expect(mockReportEvent).toHaveBeenCalledWith(
TelemetryEventType.IntegrationAssistantStepComplete,
{
sessionId: expect.any(String),
step: 3,
stepName: 'DataStream Step',
durationMs: expect.any(Number),
sessionElapsedTime: expect.any(Number),
}
);
});
});
describe('when back button is clicked', () => {
beforeEach(() => {
act(() => {
result.getByTestId('buttonsFooter-backButton').click();
});
});
it('should set step 2', () => {
expect(mockActions.setStep).toHaveBeenCalledWith(2);
});
});
});
describe('when it is generating', () => {
let result: RenderResult;
beforeEach(() => {
result = render(<Footer currentStep={3} isGenerating={true} isNextStepEnabled />, {
wrapper,
});
});
it('should render the loader', () => {
expect(result.queryByTestId('generatingLoader')).toBeInTheDocument();
});
});
});
describe('when step is 4', () => {
let result: RenderResult;
beforeEach(() => {
result = render(<Footer currentStep={4} isGenerating={false} isNextStepEnabled />, {
wrapper,
});
});
describe('when next button is clicked', () => {
beforeEach(() => {
act(() => {
result.getByTestId('buttonsFooter-nextButton').click();
});
});
it('should set step 5', () => {
expect(mockActions.setStep).toHaveBeenCalledWith(5);
});
it('should report telemetry', () => {
expect(mockReportEvent).toHaveBeenCalledWith(
TelemetryEventType.IntegrationAssistantStepComplete,
{
sessionId: expect.any(String),
step: 4,
stepName: 'Review Step',
durationMs: expect.any(Number),
sessionElapsedTime: expect.any(Number),
}
);
});
});
describe('when back button is clicked', () => {
beforeEach(() => {
act(() => {
result.getByTestId('buttonsFooter-backButton').click();
});
});
it('should set step 3', () => {
expect(mockActions.setStep).toHaveBeenCalledWith(3);
});
});
});
describe('when next step is disabled', () => {
let result: RenderResult;
beforeEach(() => {
result = render(<Footer currentStep={1} isGenerating={false} />, {
wrapper,
});
});
it('should render next button disabled', () => {
expect(result.queryByTestId('buttonsFooter-nextButton')).toBeDisabled();
});
});
});

View file

@ -20,7 +20,7 @@ const AnalyzeButtonText = React.memo<{ isGenerating: boolean }>(({ isGenerating
}
return (
<>
<EuiLoadingSpinner size="s" />
<EuiLoadingSpinner size="s" data-test-subj="generatingLoader" />
{i18n.LOADING}
</>
);
@ -30,14 +30,13 @@ AnalyzeButtonText.displayName = 'AnalyzeButtonText';
interface FooterProps {
currentStep: State['step'];
isGenerating: State['isGenerating'];
onGenerate: () => void;
isNextStepEnabled?: boolean;
}
export const Footer = React.memo<FooterProps>(
({ currentStep, onGenerate, isGenerating, isNextStepEnabled = false }) => {
({ currentStep, isGenerating, isNextStepEnabled = false }) => {
const telemetry = useTelemetry();
const { setStep } = useActions();
const { setStep, setIsGenerating } = useActions();
const navigate = useNavigate();
const onBack = useCallback(() => {
@ -51,11 +50,11 @@ export const Footer = React.memo<FooterProps>(
const onNext = useCallback(() => {
telemetry.reportAssistantStepComplete({ step: currentStep });
if (currentStep === 3) {
onGenerate();
setIsGenerating(true);
} else {
setStep(currentStep + 1);
}
}, [currentStep, onGenerate, setStep, telemetry]);
}, [currentStep, setIsGenerating, setStep, telemetry]);
const nextButtonText = useMemo(() => {
if (currentStep === 3) {

View file

@ -0,0 +1,434 @@
/*
* 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 { Pipeline, Docs } from '../../../../../common';
import type { Actions, State } from '../state';
import type { AIConnector } from '../types';
const result: { pipeline: Pipeline; docs: Docs } = {
pipeline: {
description: 'Pipeline to process my_integration my_data_stream_title logs',
processors: [
{
set: {
field: 'ecs.version',
tag: 'set_ecs_version',
value: '8.11.0',
},
},
{
rename: {
field: 'message',
target_field: 'event.original',
tag: 'rename_message',
ignore_missing: true,
if: 'ctx.event?.original == null',
},
},
{
remove: {
field: 'message',
ignore_missing: true,
tag: 'remove_message',
if: 'ctx.event?.original != null',
},
},
{
json: {
field: 'event.original',
tag: 'json_original',
target_field: 'my_integration.my_data_stream_title',
},
},
{
rename: {
field: 'my_integration.my_data_stream_title.event',
target_field: 'event.action',
ignore_missing: true,
},
},
{
rename: {
field: 'my_integration.my_data_stream_title.uid',
target_field: 'event.id',
ignore_missing: true,
},
},
{
rename: {
field: 'my_integration.my_data_stream_title.code',
target_field: 'event.code',
ignore_missing: true,
},
},
{
date: {
field: 'my_integration.my_data_stream_title.time',
target_field: '@timestamp',
formats: ['ISO8601'],
if: 'ctx.my_integration?.my_data_stream_title?.time != null',
},
},
{
rename: {
field: 'my_integration.my_data_stream_title.user',
target_field: 'user.name',
ignore_missing: true,
},
},
{
rename: {
field: 'my_integration.my_data_stream_title.user_agent',
target_field: 'user_agent.original',
ignore_missing: true,
},
},
{
rename: {
field: 'my_integration.my_data_stream_title.addr.remote',
target_field: 'source.address',
ignore_missing: true,
},
},
{
rename: {
field: 'my_integration.my_data_stream_title.server_hostname',
target_field: 'destination.domain',
ignore_missing: true,
},
},
{
rename: {
field: 'my_integration.my_data_stream_title.proto',
target_field: 'network.transport',
ignore_missing: true,
},
},
{
script: {
description: 'Drops null/empty values recursively.',
tag: 'script_drop_null_empty_values',
lang: 'painless',
source:
'boolean dropEmptyFields(Object object) {\n if (object == null || object == "") {\n return true;\n } else if (object instanceof Map) {\n ((Map) object).values().removeIf(value -> dropEmptyFields(value));\n return (((Map) object).size() == 0);\n } else if (object instanceof List) {\n ((List) object).removeIf(value -> dropEmptyFields(value));\n return (((List) object).length == 0);\n }\n return false;\n}\ndropEmptyFields(ctx);\n',
},
},
{
geoip: {
field: 'source.ip',
tag: 'geoip_source_ip',
target_field: 'source.geo',
ignore_missing: true,
},
},
{
geoip: {
ignore_missing: true,
database_file: 'GeoLite2-ASN.mmdb',
field: 'source.ip',
tag: 'geoip_source_asn',
target_field: 'source.as',
properties: ['asn', 'organization_name'],
},
},
{
rename: {
field: 'source.as.asn',
tag: 'rename_source_as_asn',
target_field: 'source.as.number',
ignore_missing: true,
},
},
{
rename: {
field: 'source.as.organization_name',
tag: 'rename_source_as_organization_name',
target_field: 'source.as.organization.name',
ignore_missing: true,
},
},
{
geoip: {
field: 'destination.ip',
tag: 'geoip_destination_ip',
target_field: 'destination.geo',
ignore_missing: true,
},
},
{
geoip: {
database_file: 'GeoLite2-ASN.mmdb',
field: 'destination.ip',
tag: 'geoip_destination_asn',
target_field: 'destination.as',
properties: ['asn', 'organization_name'],
ignore_missing: true,
},
},
{
rename: {
field: 'destination.as.asn',
tag: 'rename_destination_as_asn',
target_field: 'destination.as.number',
ignore_missing: true,
},
},
{
append: {
field: 'event.type',
value: ['start'],
allow_duplicates: false,
if: "ctx.event?.action == 'user.login'",
},
},
{
append: {
field: 'event.category',
value: ['authentication'],
allow_duplicates: false,
if: "ctx.event?.action == 'user.login'",
},
},
{
append: {
field: 'event.type',
value: ['start'],
allow_duplicates: false,
if: "ctx.event?.action == 'session.start'",
},
},
{
append: {
field: 'event.category',
value: ['session'],
allow_duplicates: false,
if: "ctx.event?.action == 'session.start'",
},
},
{
append: {
field: 'event.type',
value: ['info'],
allow_duplicates: false,
if: 'ctx.my_integration?.my_data_stream_title?.mfa_device != null',
},
},
{
append: {
field: 'event.category',
value: ['authentication'],
allow_duplicates: false,
if: 'ctx.my_integration?.my_data_stream_title?.mfa_device != null',
},
},
{
append: {
field: 'related.ip',
value: ['{{{my_integration.my_data_stream_title.addr.remote}}}'],
if: 'ctx?.my_integration?.my_data_stream_title?.addr?.remote != null',
allow_duplicates: false,
},
},
{
append: {
field: 'related.user',
value: ['{{{user.name}}}'],
allow_duplicates: false,
},
},
{
append: {
field: 'related.hosts',
value: ['{{{destination.domain}}}'],
if: 'ctx?.destination?.domain != null',
allow_duplicates: false,
},
},
{
append: {
field: 'related.hosts',
value: ['{{{my_integration.my_data_stream_title.server_labels.hostname}}}'],
if: 'ctx?.my_integration?.my_data_stream_title?.server_labels?.hostname != null',
allow_duplicates: false,
},
},
{
rename: {
field: 'destination.as.organization_name',
tag: 'rename_destination_as_organization_name',
target_field: 'destination.as.organization.name',
ignore_missing: true,
},
},
{
remove: {
field: 'event.original',
tag: 'remove_original_event',
if: 'ctx?.tags == null || !(ctx.tags.contains("preserve_original_event"))',
ignore_failure: true,
ignore_missing: true,
},
},
],
on_failure: [
{
append: {
field: 'error.message',
value:
'Processor {{{_ingest.on_failure_processor_type}}} with tag {{{_ingest.on_failure_processor_tag}}} in pipeline {{{_ingest.on_failure_pipeline}}} failed with message: {{{_ingest.on_failure_message}}}',
},
},
{
set: {
field: 'event.kind',
value: 'pipeline_error',
},
},
],
},
docs: [
{
'@timestamp': '2024-02-23T18:56:50.628Z',
ecs: {
version: '8.11.0',
},
my_integration: {
my_data_stream_title: {
cluster_name: 'teleport.ericbeahan.com',
'addr.remote': '136.61.214.196:50332',
ei: 0,
method: 'local',
required_private_key_policy: 'none',
success: true,
time: '2024-02-23T18:56:50.628Z',
mfa_device: {
mfa_device_type: 'TOTP',
mfa_device_uuid: 'd07bf388-af49-4ec2-b8a4-c8a9e785b70b',
mfa_device_name: 'otp-device',
},
},
},
related: {
user: ['teleport-admin'],
},
event: {
action: 'user.login',
code: 'T1000I',
id: 'b675d102-fc25-4f7a-bf5d-96468cc176ea',
type: ['start', 'info'],
category: ['authentication'],
},
user: {
name: 'teleport-admin',
},
user_agent: {
original:
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',
},
tags: [
'_geoip_database_unavailable_GeoLite2-City.mmdb',
'_geoip_database_unavailable_GeoLite2-ASN.mmdb',
'_geoip_database_unavailable_GeoLite2-City.mmdb',
'_geoip_database_unavailable_GeoLite2-ASN.mmdb',
],
},
{
'@timestamp': '2024-02-23T18:56:57.199Z',
ecs: {
version: '8.11.0',
},
my_integration: {
my_data_stream_title: {
cluster_name: 'teleport.ericbeahan.com',
'addr.remote': '136.61.214.196:50339',
ei: 0,
private_key_policy: 'none',
login: 'ec2-user',
server_id: 'face0091-2bf1-43fd-a16a-f1514b4119f4',
sid: '293fda2d-2266-4d4d-b9d1-bd5ea9dd9fc3',
user_kind: 1,
server_labels: {
'teleport.internal/resource-id': 'dccb2999-9fb8-4169-aded-ec7a1c0a26de',
hostname: 'ip-172-31-8-163.us-east-2.compute.internal',
},
size: '80:25',
namespace: 'default',
session_recording: 'node',
time: '2024-02-23T18:56:57.199Z',
},
},
related: {
user: ['teleport-admin'],
hosts: ['ip-172-31-8-163.us-east-2.compute.internal'],
},
destination: {
domain: 'ip-172-31-8-163.us-east-2.compute.internal',
},
event: {
action: 'session.start',
code: 'T2000I',
id: 'fff30583-13be-49e8-b159-32952c6ea34f',
type: ['start'],
category: ['session'],
},
user: {
name: 'teleport-admin',
},
network: {
transport: 'ssh',
},
tags: [
'_geoip_database_unavailable_GeoLite2-City.mmdb',
'_geoip_database_unavailable_GeoLite2-ASN.mmdb',
'_geoip_database_unavailable_GeoLite2-City.mmdb',
'_geoip_database_unavailable_GeoLite2-ASN.mmdb',
],
},
],
};
const rawSamples = [
'{"ei":0,"event":"user.login","uid":"b675d102-fc25-4f7a-bf5d-96468cc176ea","code":"T1000I","time":"2024-02-23T18:56:50.628Z","cluster_name":"teleport.ericbeahan.com","user":"teleport-admin","required_private_key_policy":"none","success":true,"method":"local","mfa_device.mfa_device_name":"otp-device","mfa_device.mfa_device_uuid":"d07bf388-af49-4ec2-b8a4-c8a9e785b70b","mfa_device.mfa_device_type":"TOTP","user_agent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36","addr.remote":"136.61.214.196:50332"}',
'{"ei":0,"event":"cert.create","uid":"efd326fc-dd13-4df8-acef-3102c2d717d3","code":"TC000I","time":"2024-02-23T18:56:50.653Z","cluster_name":"teleport.ericbeahan.com","cert_type":"user","identity.user":"teleport-admin","identity.roles":["access","editor"],"identity.logins":["root","ubuntu","ec2-user","-teleport-internal-join"],"identity.expires":"2024-02-24T06:56:50.648137154Z","identity.route_to_cluster":"teleport.ericbeahan.com","identity.traits.aws_role_arns":null,"identity.traits.azure_identities":null,"identity.traits.db_names":null,"identity.traits.db_roles":null,"identity.traits.db_users":null,"identity.traits.gcp_service_accounts":null,"identity.traits.host_user_gid":[""],"identity.traits.host_user_uid":[""],"identity.traits.kubernetes_groups":null,"identity.traits.kubernetes_users":null,"identity.traits.logins":["root","ubuntu","ec2-user"],"identity.traits.windows_logins":null,"identity.teleport_cluster":"teleport.ericbeahan.com","identity.client_ip":"136.61.214.196","identity.prev_identity_expires":"0001-01-01T00:00:00Z","identity.private_key_policy":"none"}',
'{"ei":0,"event":"session.start","uid":"fff30583-13be-49e8-b159-32952c6ea34f","code":"T2000I","time":"2024-02-23T18:56:57.199Z","cluster_name":"teleport.ericbeahan.com","user":"teleport-admin","login":"ec2-user","user_kind":1,"sid":"293fda2d-2266-4d4d-b9d1-bd5ea9dd9fc3","private_key_policy":"none","namespace":"default","server_id":"face0091-2bf1-43fd-a16a-f1514b4119f4","server_hostname":"ip-172-31-8-163.us-east-2.compute.internal","server_labels.hostname":"ip-172-31-8-163.us-east-2.compute.internal","server_labels.teleport.internal/resource-id":"dccb2999-9fb8-4169-aded-ec7a1c0a26de","addr.remote":"136.61.214.196:50339","proto":"ssh","size":"80:25","initial_command":[""],"session_recording":"node"}',
];
const logo =
'PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPGcgY2xpcC1wYXRoPSJ1cmwoI2NsaXAwXzMyNjlfMjExOTMpIj4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xMC41IDExLjk5OTdMNC40MDQgMS42ODQyMUMzLjM4Nzc1IC0wLjAzNzA0MDIgMC43NSAwLjY4NDQ2IDAuNzUgMi42ODMyMVYyMS4zMTYyQzAuNzUgMjMuMzE1IDMuMzg3NzUgMjQuMDM2NSA0LjQwNCAyMi4zMTUyTDEwLjUgMTEuOTk5N1oiIGZpbGw9IiNGMDRFOTgiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xMy4xMTMzIDEyTDEyLjQzNzYgMTMuMTQ0NUw2LjM0MTU2IDIzLjQ2QzYuMjI2ODEgMjMuNjUzNSA2LjA5NzA2IDIzLjgzMDUgNS45NTgzMSAyNEgxMi44NDAzQzE0LjY1MDggMjQgMTYuMzMzMSAyMy4wNjc3IDE3LjI5MjMgMjEuNTMyNUwyMy4yNTAzIDEySDEzLjExMzNaIiBmaWxsPSIjRkE3NDRFIi8+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMTcuMjkyNCAyLjQ2NzVDMTYuMzMzMiAwLjkzMjI1IDE0LjY1MDkgMCAxMi44Mzk3IDBINS45NTg0NEM2LjA5NjQ0IDAuMTY5NSA2LjIyNjk0IDAuMzQ2NSA2LjM0MDk0IDAuNTRMMTIuNDM2OSAxMC44NTU1TDEzLjExMzQgMTJIMjMuMjQ5N0wxNy4yOTI0IDIuNDY3NVoiIGZpbGw9IiMzNDM3NDEiLz4KPC9nPgo8ZGVmcz4KPGNsaXBQYXRoIGlkPSJjbGlwMF8zMjY5XzIxMTkzIj4KPHJlY3Qgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0IiBmaWxsPSJ3aGl0ZSIvPgo8L2NsaXBQYXRoPgo8L2RlZnM+Cjwvc3ZnPgo=';
export const mockState: State = {
step: 4,
connector: {
id: 'claudeV3OpusUsWest2',
name: 'Claude 3 Opus US-WEST-2',
actionTypeId: '.bedrock',
config: {
apiUrl: 'https://bedrock-runtime.us-west-2.amazonaws.com',
defaultModel: 'anthropic.claude-3-opus-20240229-v1:0',
},
} as AIConnector,
integrationSettings: {
title: 'Mocked Integration title',
name: 'mocked_integration',
logo,
description: 'Mocked Integration description',
dataStreamTitle: 'Mocked Data Stream Title',
dataStreamName: 'mocked_datastream_name',
dataStreamDescription: 'Mocked Data Stream Description',
inputType: 'filestream',
logsSampleParsed: rawSamples,
},
isGenerating: false,
result,
};
export const mockActions: Actions = {
setStep: jest.fn(),
setConnector: jest.fn(),
setIntegrationSettings: jest.fn(),
setIsGenerating: jest.fn(),
setResult: jest.fn(),
};

View file

@ -45,7 +45,12 @@ export const ConnectorSelector = React.memo<ConnectorSelectorProps>(
const { setConnector } = useActions();
const rowCss = useRowCss();
return (
<>
<EuiFlexGroup
alignItems="stretch"
direction="column"
gutterSize="s"
data-test-subj="connectorSelector"
>
{connectors.map((connector) => (
<EuiFlexItem key={connector.id}>
<EuiPanel
@ -55,6 +60,7 @@ export const ConnectorSelector = React.memo<ConnectorSelectorProps>(
hasBorder
paddingSize="l"
css={rowCss}
data-test-subj={`connectorSelector-${connector.id}`}
>
<EuiFlexGroup direction="row" alignItems="center" justifyContent="spaceBetween">
<EuiFlexItem>
@ -63,6 +69,9 @@ export const ConnectorSelector = React.memo<ConnectorSelectorProps>(
id={connector.id}
checked={selectedConnectorId === connector.id}
onChange={noop}
data-test-subj={`connectorSelectorRadio-${connector.id}${
selectedConnectorId === connector.id ? '-selected' : ''
}`}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
@ -74,7 +83,7 @@ export const ConnectorSelector = React.memo<ConnectorSelectorProps>(
</EuiPanel>
</EuiFlexItem>
))}
</>
</EuiFlexGroup>
);
}
);

View file

@ -50,7 +50,6 @@ export const ConnectorSetup = React.memo<ConnectorSetupProps>(
notifications: { toasts },
} = useKibana().services;
const [selectedActionType, setSelectedActionType] = useState<ActionType | null>(null);
const onModalClose = useCallback(() => {
setSelectedActionType(null);
onClose?.();
@ -74,6 +73,7 @@ export const ConnectorSetup = React.memo<ConnectorSetupProps>(
{compressed ? (
<EuiListGroup
flush
data-test-subj="connectorSetupCompressed"
listItems={actionTypes.map((actionType) => ({
id: actionType.id,
label: actionType.name,
@ -90,10 +90,13 @@ export const ConnectorSetup = React.memo<ConnectorSetupProps>(
}))}
/>
) : (
<EuiFlexGroup direction="column" gutterSize="s">
<EuiFlexGroup direction="column" gutterSize="s" data-test-subj="connectorSetupPage">
{actionTypes?.map((actionType: ActionType) => (
<EuiFlexItem data-test-subj="action-option" key={actionType.id}>
<EuiLink onClick={() => setSelectedActionType(actionType)}>
<EuiFlexItem key={actionType.id}>
<EuiLink
onClick={() => setSelectedActionType(actionType)}
data-test-subj={`actionType-${actionType.id}`}
>
<EuiPanel hasShadow={false} hasBorder paddingSize="l" css={panelCss}>
<EuiFlexGroup direction="row" alignItems="center" gutterSize="s">
<EuiFlexItem grow={false}>

View file

@ -0,0 +1,187 @@
/*
* 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 { render, act, type RenderResult } from '@testing-library/react';
import type { ActionTypeModel } from '@kbn/triggers-actions-ui-plugin/public';
import { TestProvider } from '../../../../../mocks/test_provider';
import { ConnectorStep } from './connector_step';
import { ActionsProvider } from '../../state';
import { mockActions, mockState } from '../../mocks/state';
import { useAuthorization } from '../../../../../common/hooks/use_authorization';
import { mockServices } from '../../../../../services/mocks/services';
import type { AIConnector } from '../../types';
jest.mock('../../../../../common/hooks/use_authorization');
const mockUseAuthorization = useAuthorization as jest.Mock;
const connector = mockState.connector!;
const defaultUseMockConnectors: { data: AIConnector[]; isLoading: boolean; refetch: Function } = {
data: [],
isLoading: false,
refetch: jest.fn(),
};
const mockUseLoadConnectors = jest.fn(() => defaultUseMockConnectors);
jest.mock('@kbn/elastic-assistant', () => ({
useLoadConnectors: () => mockUseLoadConnectors(),
}));
const actionType = { id: '.bedrock', name: 'Bedrock', iconClass: 'logoBedrock' };
mockServices.triggersActionsUi.actionTypeRegistry.register(
actionType as unknown as ActionTypeModel
);
jest.mock('@kbn/elastic-assistant/impl/connectorland/use_load_action_types', () => ({
useLoadActionTypes: jest.fn(() => ({ data: [actionType] })),
}));
jest.mock('@kbn/triggers-actions-ui-plugin/public/common/constants', () => ({
ConnectorAddModal: () => <div data-test-subj="connectorAddModal" />,
}));
const wrapper: React.FC = ({ children }) => (
<TestProvider>
<ActionsProvider value={mockActions}>{children}</ActionsProvider>
</TestProvider>
);
describe('ConnectorStep', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('when no connector selected', () => {
describe('when no connector exists', () => {
let result: RenderResult;
beforeEach(() => {
mockUseLoadConnectors.mockReturnValue({ ...defaultUseMockConnectors, data: [] });
result = render(<ConnectorStep connector={undefined} />, { wrapper });
});
it('should render connector setup page', () => {
expect(result.queryByTestId('connectorSetupPage')).toBeInTheDocument();
expect(result.queryByTestId('connectorSetupCompressed')).not.toBeInTheDocument();
});
it('should not render connector selector page', () => {
expect(result.queryByTestId('connectorSelector')).not.toBeInTheDocument();
});
it('should not render connector setup popover', () => {
expect(result.queryByTestId('createConnectorPopover')).not.toBeInTheDocument();
});
describe('when connector type selected clicked', () => {
beforeEach(() => {
act(() => {
result.getByTestId(`actionType-${actionType.id}`).click();
});
});
it('should render connector creation modal', () => {
expect(result.queryByTestId('connectorAddModal')).toBeInTheDocument();
});
});
});
describe('when connectors exist', () => {
let result: RenderResult;
beforeEach(() => {
mockUseLoadConnectors.mockReturnValue({ ...defaultUseMockConnectors, data: [connector] });
result = render(<ConnectorStep connector={undefined} />, { wrapper });
});
it('should render connector selector page', () => {
expect(result.queryByTestId('connectorSelector')).toBeInTheDocument();
});
it('should not render connector setup page', () => {
expect(result.queryByTestId('connectorSetup')).not.toBeInTheDocument();
});
it('should render connector setup popover', () => {
expect(result.queryByTestId('createConnectorPopover')).toBeInTheDocument();
});
describe('when connector clicked', () => {
beforeEach(() => {
act(() => {
result.getByTestId(`connectorSelector-${connector.id}`).click();
});
});
it('should dispatch setConnector', () => {
expect(mockActions.setConnector).toHaveBeenCalledWith(connector);
});
});
describe('when add connector popover is clicked', () => {
beforeEach(() => {
act(() => {
result.getByTestId('createConnectorPopoverButton').click();
});
});
it('should render connector setup compressed', () => {
expect(result.queryByTestId('connectorSetupCompressed')).toBeInTheDocument();
expect(result.queryByTestId('connectorSetupPage')).not.toBeInTheDocument();
});
});
});
});
describe('when connector selected', () => {
let result: RenderResult;
beforeEach(() => {
mockUseLoadConnectors.mockReturnValue({ ...defaultUseMockConnectors, data: [connector] });
result = render(<ConnectorStep connector={connector} />, { wrapper });
});
it('should render connector selector page', () => {
expect(result.queryByTestId('connectorSelector')).toBeInTheDocument();
});
it('should render connector selected', () => {
expect(
result.queryByTestId(`connectorSelectorRadio-${connector.id}-selected`)
).toBeInTheDocument();
});
});
describe('when create connector privileges missing', () => {
let result: RenderResult;
beforeEach(() => {
mockUseAuthorization.mockReturnValue({ canCreateConnectors: false });
});
describe('when no connector exists', () => {
beforeEach(() => {
mockUseLoadConnectors.mockReturnValue({ ...defaultUseMockConnectors, data: [] });
result = render(<ConnectorStep connector={undefined} />, { wrapper });
});
it('should not render connector setup page', () => {
expect(result.queryByTestId('connectorSetup')).not.toBeInTheDocument();
});
it('should render the missing privilege callout', () => {
expect(result.queryByTestId('missingPrivilegesCallOut')).toBeInTheDocument();
});
});
describe('when connectors exist', () => {
beforeEach(() => {
mockUseLoadConnectors.mockReturnValue({ ...defaultUseMockConnectors, data: [connector] });
result = render(<ConnectorStep connector={undefined} />, { wrapper });
});
it('should render connector selector page', () => {
expect(result.queryByTestId('connectorSelector')).toBeInTheDocument();
});
it('should render the disabled create connector link', () => {
expect(result.queryByTestId('createConnectorPopoverButtonDisabled')).toBeInTheDocument();
});
});
});
});

View file

@ -70,9 +70,7 @@ export const ConnectorStep = React.memo<ConnectorStepProps>(({ connector }) => {
) : (
<>
{hasConnectors ? (
<EuiFlexGroup alignItems="stretch" direction="column" gutterSize="s">
<ConnectorSelector connectors={connectors} selectedConnectorId={connector?.id} />
</EuiFlexGroup>
<ConnectorSelector connectors={connectors} selectedConnectorId={connector?.id} />
) : (
<AuthorizationWrapper canCreateConnectors>
<ConnectorSetup
@ -107,15 +105,22 @@ const CreateConnectorPopover = React.memo<CreateConnectorPopoverProps>(({ onConn
if (!canCreateConnectors) {
return (
<MissingPrivilegesTooltip canCreateConnectors>
<EuiLink disabled>{i18n.CREATE_CONNECTOR}</EuiLink>
<EuiLink data-test-subj="createConnectorPopoverButtonDisabled" disabled>
{i18n.CREATE_CONNECTOR}
</EuiLink>
</MissingPrivilegesTooltip>
);
}
return (
<EuiPopover
button={<EuiLink onClick={openPopover}>{i18n.CREATE_CONNECTOR}</EuiLink>}
button={
<EuiLink data-test-subj="createConnectorPopoverButton" onClick={openPopover}>
{i18n.CREATE_CONNECTOR}
</EuiLink>
}
isOpen={isOpen}
closePopover={closePopover}
data-test-subj="createConnectorPopover"
>
<EuiFlexGroup alignItems="flexStart">
<EuiFlexItem grow={false}>

View file

@ -0,0 +1,230 @@
/*
* 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 { render, act, type RenderResult, fireEvent } from '@testing-library/react';
import { TestProvider } from '../../../../../mocks/test_provider';
import { DataStreamStep } from './data_stream_step';
import { ActionsProvider } from '../../state';
import { mockActions, mockState } from '../../mocks/state';
jest.mock('./generation_modal', () => ({
GenerationModal: jest.fn(() => <div data-test-subj="generationModal" />),
}));
const duplicatePackageName = 'valid_but_duplicate_package_name';
jest.mock('./use_load_package_names', () => {
return {
useLoadPackageNames: jest.fn(() => ({
isLoading: false,
packageNames: new Set([duplicatePackageName]),
})),
};
});
const wrapper: React.FC = ({ children }) => (
<TestProvider>
<ActionsProvider value={mockActions}>{children}</ActionsProvider>
</TestProvider>
);
describe('DataStreamStep', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('when open with initial state', () => {
let result: RenderResult;
beforeEach(() => {
jest.clearAllMocks();
result = render(
<DataStreamStep
integrationSettings={undefined}
connector={mockState.connector}
isGenerating={false}
/>,
{ wrapper }
);
});
it('should render data stream step page', () => {
expect(result.queryByTestId('dataStreamStep')).toBeInTheDocument();
});
describe('when package name changes', () => {
let input: HTMLElement;
beforeEach(() => {
input = result.getByTestId('nameInput');
});
describe('with a valid name', () => {
const name = 'valid_package_name_1';
beforeEach(() => {
act(() => {
fireEvent.change(input, { target: { value: name } });
});
});
it('should not render invalid input', () => {
expect(input).not.toHaveAttribute('aria-invalid');
});
it('should call setIntegrationSettings', () => {
expect(mockActions.setIntegrationSettings).toHaveBeenCalledWith({ name });
});
});
describe('with an invalid name', () => {
describe.each(['package name', 'package-name', 'packageName', 'package.name'])(
'should render error for %s',
(name) => {
beforeEach(() => {
act(() => {
fireEvent.change(input, { target: { value: name } });
});
});
it('should render invalid input', () => {
expect(input).toHaveAttribute('aria-invalid');
});
it('should call setIntegrationSettings with undefined', () => {
expect(mockActions.setIntegrationSettings).toHaveBeenCalledWith({
name: undefined,
});
});
}
);
});
describe('with a duplicate name', () => {
beforeEach(() => {
act(() => {
fireEvent.change(input, { target: { value: duplicatePackageName } });
});
});
it('should render invalid input', () => {
expect(input).toHaveAttribute('aria-invalid');
});
it('should call setIntegrationSettings with undefined', () => {
expect(mockActions.setIntegrationSettings).toHaveBeenCalledWith({ name: undefined });
});
});
});
describe('when dataStreamTitle changes', () => {
const dataStreamTitle = 'Data stream title';
beforeEach(() => {
act(() => {
fireEvent.change(result.getByTestId('dataStreamTitleInput'), {
target: { value: dataStreamTitle },
});
});
});
it('should call setIntegrationSettings', () => {
expect(mockActions.setIntegrationSettings).toHaveBeenCalledWith({ dataStreamTitle });
});
});
describe('when dataStreamDescription changes', () => {
const dataStreamDescription = 'Data stream description';
beforeEach(() => {
act(() => {
fireEvent.change(result.getByTestId('dataStreamDescriptionInput'), {
target: { value: dataStreamDescription },
});
});
});
it('should call setIntegrationSettings', () => {
expect(mockActions.setIntegrationSettings).toHaveBeenCalledWith({
dataStreamDescription,
});
});
});
describe('when dataStreamName changes', () => {
let input: HTMLElement;
beforeEach(() => {
input = result.getByTestId('dataStreamNameInput');
});
describe('with a valid name', () => {
const dataStreamName = 'valid_data_stream_name_1';
beforeEach(() => {
act(() => {
fireEvent.change(input, { target: { value: dataStreamName } });
});
});
it('should not render invalid input', () => {
expect(input).not.toHaveAttribute('aria-invalid');
});
it('should call setIntegrationSettings', () => {
expect(mockActions.setIntegrationSettings).toHaveBeenCalledWith({ dataStreamName });
});
});
describe('with an invalid name', () => {
describe.each([
'data stream name',
'data-stream-name',
'dataStreamName',
'data.stream.name',
])('should render error for %s', (dataStreamName) => {
beforeEach(() => {
act(() => {
fireEvent.change(input, { target: { value: dataStreamName } });
});
});
it('should render invalid input', () => {
expect(input).toHaveAttribute('aria-invalid');
});
it('should call setIntegrationSettings with undefined', () => {
expect(mockActions.setIntegrationSettings).toHaveBeenCalledWith({ name: undefined });
});
});
});
});
describe('when dataCollectionMethod changes', () => {
const dataCollectionMethod = 'kafka';
beforeEach(() => {
act(() => {
fireEvent.change(result.getByTestId('dataCollectionMethodInput'), {
target: { value: dataCollectionMethod },
});
});
});
it('should call setIntegrationSettings', () => {
expect(mockActions.setIntegrationSettings).toHaveBeenCalledWith({
inputType: dataCollectionMethod,
});
});
});
});
describe('when is generating', () => {
let result: RenderResult;
beforeEach(() => {
result = render(
<DataStreamStep
integrationSettings={mockState.integrationSettings}
connector={mockState.connector}
isGenerating={true}
/>,
{ wrapper }
);
});
it('should render generation modal', () => {
expect(result.queryByTestId('generationModal')).toBeInTheDocument();
});
});
});

View file

@ -136,7 +136,7 @@ export const DataStreamStep = React.memo<DataStreamStepProps>(
}, [packageNames, name]);
return (
<EuiFlexGroup direction="column" gutterSize="l">
<EuiFlexGroup direction="column" gutterSize="l" data-test-subj="dataStreamStep">
<EuiFlexItem>
<StepContentWrapper
title={i18n.INTEGRATION_NAME_TITLE}
@ -154,6 +154,7 @@ export const DataStreamStep = React.memo<DataStreamStepProps>(
>
<EuiFieldText
name="name"
data-test-subj="nameInput"
value={name}
onChange={onChange.name}
isInvalid={invalidFields.name}
@ -176,6 +177,7 @@ export const DataStreamStep = React.memo<DataStreamStepProps>(
<EuiFormRow label={i18n.DATA_STREAM_TITLE_LABEL}>
<EuiFieldText
name="dataStreamTitle"
data-test-subj="dataStreamTitleInput"
value={integrationSettings?.dataStreamTitle ?? ''}
onChange={onChange.dataStreamTitle}
/>
@ -183,6 +185,7 @@ export const DataStreamStep = React.memo<DataStreamStepProps>(
<EuiFormRow label={i18n.DATA_STREAM_DESCRIPTION_LABEL}>
<EuiFieldText
name="dataStreamDescription"
data-test-subj="dataStreamDescriptionInput"
value={integrationSettings?.dataStreamDescription ?? ''}
onChange={onChange.dataStreamDescription}
/>
@ -195,6 +198,7 @@ export const DataStreamStep = React.memo<DataStreamStepProps>(
>
<EuiFieldText
name="dataStreamName"
data-test-subj="dataStreamNameInput"
value={dataStreamName}
onChange={onChange.dataStreamName}
isInvalid={invalidFields.dataStreamName}
@ -203,15 +207,13 @@ export const DataStreamStep = React.memo<DataStreamStepProps>(
<EuiFormRow label={i18n.DATA_COLLECTION_METHOD_LABEL}>
<EuiSelect
name="dataCollectionMethod"
data-test-subj="dataCollectionMethodInput"
options={InputTypeOptions}
value={integrationSettings?.inputType ?? ''}
onChange={onChange.inputType}
/>
</EuiFormRow>
<SampleLogsInput
integrationSettings={integrationSettings}
setIntegrationSettings={setIntegrationSettings}
/>
<SampleLogsInput integrationSettings={integrationSettings} />
</EuiForm>
</EuiPanel>
</StepContentWrapper>

View file

@ -0,0 +1,176 @@
/*
* 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 { render, act, type RenderResult, waitFor } from '@testing-library/react';
import { mockReportEvent } from '../../../../../services/telemetry/mocks/service';
import { TestProvider } from '../../../../../mocks/test_provider';
import { GenerationModal } from './generation_modal';
import { ActionsProvider } from '../../state';
import { mockActions, mockState } from '../../mocks/state';
import { TelemetryEventType } from '../../../../../services/telemetry/types';
const integrationSettings = mockState.integrationSettings!;
const connector = mockState.connector!;
const mockEcsMappingResults = { pipeline: { test: 'ecsMappingResponse' }, docs: [] };
const mockCategorizationResults = { pipeline: { test: 'categorizationResponse' }, docs: [] };
const mockRelatedResults = { pipeline: { test: 'relatedResponse' }, docs: [] };
const mockRunEcsGraph = jest.fn((_: unknown) => ({ results: mockEcsMappingResults }));
const mockRunCategorizationGraph = jest.fn((_: unknown) => ({
results: mockCategorizationResults,
}));
const mockRunRelatedGraph = jest.fn((_: unknown) => ({ results: mockRelatedResults }));
const defaultRequest = {
packageName: integrationSettings.name ?? '',
dataStreamName: integrationSettings.dataStreamName ?? '',
rawSamples: integrationSettings.logsSampleParsed ?? [],
connectorId: connector.id,
};
jest.mock('../../../../../common/lib/api', () => ({
runEcsGraph: (params: unknown) => mockRunEcsGraph(params),
runCategorizationGraph: (params: unknown) => mockRunCategorizationGraph(params),
runRelatedGraph: (params: unknown) => mockRunRelatedGraph(params),
}));
const mockOnComplete = jest.fn();
const mockOnClose = jest.fn();
const wrapper: React.FC = ({ children }) => (
<TestProvider>
<ActionsProvider value={mockActions}>{children}</ActionsProvider>
</TestProvider>
);
describe('GenerationModal', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('when there are no errors', () => {
let result: RenderResult;
beforeEach(async () => {
await act(async () => {
result = render(
<GenerationModal
integrationSettings={integrationSettings}
connector={connector}
onComplete={mockOnComplete}
onClose={mockOnClose}
/>,
{ wrapper }
);
await waitFor(() => expect(mockOnComplete).toBeCalled());
});
});
it('should render generation modal', () => {
expect(result.queryByTestId('generationModal')).toBeInTheDocument();
});
it('should call runEcsGraph with correct parameters', () => {
expect(mockRunEcsGraph).toHaveBeenCalledWith(defaultRequest);
});
it('should call runCategorizationGraph with correct parameters', () => {
expect(mockRunCategorizationGraph).toHaveBeenCalledWith({
...defaultRequest,
currentPipeline: mockEcsMappingResults.pipeline,
});
});
it('should call runRelatedGraph with correct parameters', () => {
expect(mockRunRelatedGraph).toHaveBeenCalledWith({
...defaultRequest,
currentPipeline: mockCategorizationResults.pipeline,
});
});
it('should call onComplete with the results', () => {
expect(mockOnComplete).toHaveBeenCalledWith(mockRelatedResults);
});
it('should report telemetry for generation complete', () => {
expect(mockReportEvent).toHaveBeenCalledWith(
TelemetryEventType.IntegrationAssistantGenerationComplete,
{
sessionId: expect.any(String),
sampleRows: integrationSettings.logsSampleParsed?.length ?? 0,
actionTypeId: connector.actionTypeId,
model: expect.anything(),
provider: connector.apiProvider ?? 'unknown',
durationMs: expect.any(Number),
errorMessage: undefined,
}
);
});
});
describe('when there are errors', () => {
const errorMessage = 'error message';
let result: RenderResult;
beforeEach(async () => {
mockRunEcsGraph.mockImplementationOnce(() => {
throw new Error(errorMessage);
});
await act(async () => {
result = render(
<GenerationModal
integrationSettings={integrationSettings}
connector={connector}
onComplete={mockOnComplete}
onClose={mockOnClose}
/>,
{ wrapper }
);
await waitFor(() =>
expect(result.queryByTestId('generationErrorCallout')).toBeInTheDocument()
);
});
});
it('should show the error text', () => {
expect(result.queryByText(errorMessage)).toBeInTheDocument();
});
it('should render the retry button', () => {
expect(result.queryByTestId('retryButton')).toBeInTheDocument();
});
it('should report telemetry for generation error', () => {
expect(mockReportEvent).toHaveBeenCalledWith(
TelemetryEventType.IntegrationAssistantGenerationComplete,
{
sessionId: expect.any(String),
sampleRows: integrationSettings.logsSampleParsed?.length ?? 0,
actionTypeId: connector.actionTypeId,
model: expect.anything(),
provider: connector.apiProvider ?? 'unknown',
durationMs: expect.any(Number),
errorMessage,
}
);
});
describe('when the retrying successfully', () => {
beforeEach(async () => {
await act(async () => {
result.getByTestId('retryButton').click();
await waitFor(() => expect(mockOnComplete).toBeCalled());
});
});
it('should not render the error callout', () => {
expect(result.queryByTestId('generationErrorCallout')).not.toBeInTheDocument();
});
it('should not render the retry button', () => {
expect(result.queryByTestId('retryButton')).not.toBeInTheDocument();
});
});
});
});

View file

@ -163,11 +163,7 @@ export const useGeneration = ({
setIsRequesting(true);
}, []);
return {
progress,
error,
retry,
};
return { progress, error, retry };
};
const useModalCss = () => {
@ -205,7 +201,7 @@ export const GenerationModal = React.memo<GenerationModalProps>(
);
return (
<EuiModal onClose={onClose}>
<EuiModal onClose={onClose} data-test-subj="generationModal">
<EuiModalHeader css={headerCss}>
<EuiModalHeaderTitle>{i18n.ANALYZING}</EuiModalHeaderTitle>
</EuiModalHeader>
@ -219,6 +215,7 @@ export const GenerationModal = React.memo<GenerationModalProps>(
title={i18n.GENERATION_ERROR(progressText[progress])}
color="danger"
iconType="alert"
data-test-subj="generationErrorCallout"
>
{error}
</EuiCallOut>
@ -256,7 +253,7 @@ export const GenerationModal = React.memo<GenerationModalProps>(
{error ? (
<EuiFlexGroup justifyContent="center">
<EuiFlexItem grow={false}>
<EuiButtonEmpty iconType="refresh" onClick={retry}>
<EuiButtonEmpty iconType="refresh" onClick={retry} data-test-subj="retryButton">
{i18n.RETRY}
</EuiButtonEmpty>
</EuiFlexItem>

View file

@ -0,0 +1,154 @@
/*
* 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 { act, fireEvent, render, waitFor, type RenderResult } from '@testing-library/react';
import { TestProvider } from '../../../../../mocks/test_provider';
import { SampleLogsInput } from './sample_logs_input';
import { ActionsProvider } from '../../state';
import { mockActions } from '../../mocks/state';
import { mockServices } from '../../../../../services/mocks/services';
const wrapper: React.FC = ({ children }) => (
<TestProvider>
<ActionsProvider value={mockActions}>{children}</ActionsProvider>
</TestProvider>
);
const changeFile = async (input: HTMLElement, file: File) => {
await act(async () => {
fireEvent.change(input, { target: { files: [file] } });
await waitFor(() => expect(input).toHaveAttribute('data-loading', 'true'));
await waitFor(() => expect(input).toHaveAttribute('data-loading', 'false'));
});
};
describe('SampleLogsInput', () => {
let result: RenderResult;
let input: HTMLElement;
beforeEach(() => {
jest.clearAllMocks();
result = render(<SampleLogsInput integrationSettings={undefined} />, { wrapper });
input = result.getByTestId('logsSampleFilePicker');
});
describe('when uploading a json logs sample', () => {
const type = 'application/json';
describe('when the file is valid', () => {
const logsSampleRaw = `{"message":"test message 1"},{"message":"test message 2"}`;
beforeEach(async () => {
await changeFile(input, new File([`[${logsSampleRaw}]`], 'test.json', { type }));
});
it('should set the integrationSetting correctly', () => {
expect(mockActions.setIntegrationSettings).toBeCalledWith({
logsSampleParsed: logsSampleRaw.split(','),
});
});
describe('when the file has too many rows', () => {
const tooLargeLogsSample = Array(6).fill(logsSampleRaw).join(','); // 12 entries
beforeEach(async () => {
await changeFile(input, new File([`[${tooLargeLogsSample}]`], 'test.json', { type }));
});
it('should truncate the logs sample', () => {
expect(mockActions.setIntegrationSettings).toBeCalledWith({
logsSampleParsed: tooLargeLogsSample.split(',').slice(0, 10),
});
});
it('should add a notification toast', () => {
expect(mockServices.notifications.toasts.addInfo).toBeCalledWith(
`The logs sample has been truncated to 10 rows.`
);
});
});
});
describe('when the file is invalid', () => {
describe.each([
['[{"message":"test message 1"}', `The logs sample file has not a valid ${type} format`],
['["test message 1"]', 'The logs sample file contains non-object entries'],
['{"message":"test message 1"}', 'The logs sample file is not an array'],
['[]', 'The logs sample file is empty'],
])('with logs content %s', (logsSample, errorMessage) => {
beforeEach(async () => {
await changeFile(input, new File([logsSample], 'test.json', { type }));
});
it('should render error message', () => {
expect(result.queryByText(errorMessage)).toBeInTheDocument();
});
it('should set the integrationSetting correctly', () => {
expect(mockActions.setIntegrationSettings).toBeCalledWith({
logsSampleParsed: undefined,
});
});
});
});
});
describe('when setting a ndjson logs sample', () => {
const type = 'application/x-ndjson';
describe('when the file is valid', () => {
const logsSampleRaw = `{"message":"test message 1"}\n{"message":"test message 2"}`;
beforeEach(async () => {
await changeFile(input, new File([logsSampleRaw], 'test.json', { type }));
});
it('should set the integrationSetting correctly', () => {
expect(mockActions.setIntegrationSettings).toBeCalledWith({
logsSampleParsed: logsSampleRaw.split('\n'),
});
});
describe('when the file has too many rows', () => {
const tooLargeLogsSample = Array(6).fill(logsSampleRaw).join('\n'); // 12 entries
beforeEach(async () => {
await changeFile(input, new File([tooLargeLogsSample], 'test.json', { type }));
});
it('should truncate the logs sample', () => {
expect(mockActions.setIntegrationSettings).toBeCalledWith({
logsSampleParsed: tooLargeLogsSample.split('\n').slice(0, 10),
});
});
it('should add a notification toast', () => {
expect(mockServices.notifications.toasts.addInfo).toBeCalledWith(
`The logs sample has been truncated to 10 rows.`
);
});
});
});
describe('when the file is invalid', () => {
describe.each([
['{"message":"test message 1"]', `The logs sample file has not a valid ${type} format`],
['"test message 1"', 'The logs sample file contains non-object entries'],
['', 'The logs sample file is empty'],
])('with logs content %s', (logsSample, errorMessage) => {
beforeEach(async () => {
await changeFile(input, new File([logsSample], 'test.json', { type }));
});
it('should render error message', () => {
expect(result.queryByText(errorMessage)).toBeInTheDocument();
});
it('should set the integrationSetting correctly', () => {
expect(mockActions.setIntegrationSettings).toBeCalledWith({
logsSampleParsed: undefined,
});
});
});
});
});
});

View file

@ -11,6 +11,7 @@ import { useKibana } from '@kbn/kibana-react-plugin/public';
import { isPlainObject } from 'lodash/fp';
import type { IntegrationSettings } from '../../types';
import * as i18n from './translations';
import { useActions } from '../../state';
const MaxLogsSampleRows = 10;
@ -61,88 +62,88 @@ const parseLogsContent = (
interface SampleLogsInputProps {
integrationSettings: IntegrationSettings | undefined;
setIntegrationSettings: (param: IntegrationSettings) => void;
}
export const SampleLogsInput = React.memo<SampleLogsInputProps>(
({ integrationSettings, setIntegrationSettings }) => {
const { notifications } = useKibana().services;
const [isParsing, setIsParsing] = useState(false);
const [sampleFileError, setSampleFileError] = useState<string>();
export const SampleLogsInput = React.memo<SampleLogsInputProps>(({ integrationSettings }) => {
const { notifications } = useKibana().services;
const { setIntegrationSettings } = useActions();
const [isParsing, setIsParsing] = useState(false);
const [sampleFileError, setSampleFileError] = useState<string>();
const onChangeLogsSample = useCallback(
(files: FileList | null) => {
const logsSampleFile = files?.[0];
if (logsSampleFile == null) {
setSampleFileError(undefined);
const onChangeLogsSample = useCallback(
(files: FileList | null) => {
const logsSampleFile = files?.[0];
if (logsSampleFile == null) {
setSampleFileError(undefined);
setIntegrationSettings({ ...integrationSettings, logsSampleParsed: undefined });
return;
}
const reader = new FileReader();
reader.onload = function (e) {
const fileContent = e.target?.result as string | undefined; // We can safely cast to string since we call `readAsText` to load the file.
const { error, isTruncated, logsSampleParsed } = parseLogsContent(
fileContent,
logsSampleFile.type
);
setIsParsing(false);
setSampleFileError(error);
if (error) {
setIntegrationSettings({ ...integrationSettings, logsSampleParsed: undefined });
return;
}
const reader = new FileReader();
reader.onload = function (e) {
const fileContent = e.target?.result as string | undefined; // We can safely cast to string since we call `readAsText` to load the file.
const { error, isTruncated, logsSampleParsed } = parseLogsContent(
fileContent,
logsSampleFile.type
);
setIsParsing(false);
setSampleFileError(error);
if (error) {
setIntegrationSettings({ ...integrationSettings, logsSampleParsed: undefined });
return;
}
if (isTruncated) {
notifications?.toasts.addInfo(i18n.LOGS_SAMPLE_TRUNCATED(MaxLogsSampleRows));
}
setIntegrationSettings({
...integrationSettings,
logsSampleParsed,
});
};
setIsParsing(true);
reader.readAsText(logsSampleFile);
},
[integrationSettings, setIntegrationSettings, notifications?.toasts, setIsParsing]
);
return (
<EuiFormRow
label={i18n.LOGS_SAMPLE_LABEL}
helpText={
<EuiText color="danger" size="xs">
{sampleFileError}
</EuiText>
if (isTruncated) {
notifications?.toasts.addInfo(i18n.LOGS_SAMPLE_TRUNCATED(MaxLogsSampleRows));
}
isInvalid={sampleFileError != null}
>
<>
<EuiCallOut iconType="iInCircle" color="warning">
{i18n.LOGS_SAMPLE_WARNING}
</EuiCallOut>
<EuiSpacer size="s" />
<EuiFilePicker
id="logsSampleFilePicker"
initialPromptText={
<>
<EuiText size="s" textAlign="center">
{i18n.LOGS_SAMPLE_DESCRIPTION}
</EuiText>
<EuiText size="xs" color="subdued" textAlign="center">
{i18n.LOGS_SAMPLE_DESCRIPTION_2}
</EuiText>
</>
}
onChange={onChangeLogsSample}
display="large"
aria-label="Upload logs sample file"
accept="application/json,application/x-ndjson"
isLoading={isParsing}
/>
</>
</EuiFormRow>
);
}
);
setIntegrationSettings({
...integrationSettings,
logsSampleParsed,
});
};
setIsParsing(true);
reader.readAsText(logsSampleFile);
},
[integrationSettings, setIntegrationSettings, notifications?.toasts, setIsParsing]
);
return (
<EuiFormRow
label={i18n.LOGS_SAMPLE_LABEL}
helpText={
<EuiText color="danger" size="xs">
{sampleFileError}
</EuiText>
}
isInvalid={sampleFileError != null}
>
<>
<EuiCallOut iconType="iInCircle" color="warning">
{i18n.LOGS_SAMPLE_WARNING}
</EuiCallOut>
<EuiSpacer size="s" />
<EuiFilePicker
id="logsSampleFilePicker"
initialPromptText={
<>
<EuiText size="s" textAlign="center">
{i18n.LOGS_SAMPLE_DESCRIPTION}
</EuiText>
<EuiText size="xs" color="subdued" textAlign="center">
{i18n.LOGS_SAMPLE_DESCRIPTION_2}
</EuiText>
</>
}
onChange={onChangeLogsSample}
display="large"
aria-label="Upload logs sample file"
accept="application/json,application/x-ndjson"
isLoading={isParsing}
data-test-subj="logsSampleFilePicker"
data-loading={isParsing}
/>
</>
</EuiFormRow>
);
});
SampleLogsInput.displayName = 'SampleLogsInput';

View file

@ -0,0 +1,238 @@
/*
* 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 { render, act, type RenderResult, waitFor } from '@testing-library/react';
import { TestProvider } from '../../../../../mocks/test_provider';
import { DeployStep } from './deploy_step';
import { ActionsProvider } from '../../state';
import { mockActions, mockState } from '../../mocks/state';
import type { BuildIntegrationRequestBody } from '../../../../../../common';
import { mockReportEvent } from '../../../../../services/telemetry/mocks/service';
import { TelemetryEventType } from '../../../../../services/telemetry/types';
const integrationSettings = mockState.integrationSettings!;
const connector = mockState.connector!;
const results = mockState.result!;
const parameters: BuildIntegrationRequestBody = {
integration: {
title: integrationSettings.title!,
description: integrationSettings.description!,
name: integrationSettings.name!,
logo: integrationSettings.logo,
dataStreams: [
{
title: integrationSettings.dataStreamTitle!,
description: integrationSettings.dataStreamDescription!,
name: integrationSettings.dataStreamName!,
inputTypes: [integrationSettings.inputType!],
rawSamples: integrationSettings.logsSampleParsed!,
docs: results.docs!,
pipeline: results.pipeline,
},
],
},
};
const builtIntegration = new Blob();
const mockRunBuildIntegration = jest.fn((_: unknown) => builtIntegration);
const integrationName = 'my_integration_33-1.0.0';
const mockRunInstallPackage = jest.fn((_: unknown) => ({
response: [{ id: 'audit-my_integration_33.data-stream-1.0.0' }],
}));
jest.mock('../../../../../common/lib/api', () => ({
runBuildIntegration: (params: unknown) => mockRunBuildIntegration(params),
runInstallPackage: (params: unknown) => mockRunInstallPackage(params),
}));
const mockSaveAs = jest.fn();
jest.mock('@elastic/filesaver', () => ({
saveAs: (...params: unknown[]) => mockSaveAs(...params),
}));
const wrapper: React.FC = ({ children }) => (
<TestProvider>
<ActionsProvider value={mockActions}>{children}</ActionsProvider>
</TestProvider>
);
describe('DeployStep', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('when deploy is successful', () => {
let result: RenderResult;
beforeEach(async () => {
await act(async () => {
result = render(
<DeployStep
integrationSettings={integrationSettings}
connector={connector}
result={results}
/>,
{ wrapper }
);
await waitFor(() => expect(result.queryByTestId('deployStep-loading')).toBeInTheDocument());
await waitFor(() =>
expect(result.queryByTestId('deployStep-loading')).not.toBeInTheDocument()
);
});
});
it('should call build integration api', () => {
expect(mockRunBuildIntegration).toHaveBeenCalledWith(parameters);
});
it('should call install package api', () => {
expect(mockRunInstallPackage).toHaveBeenCalledWith(builtIntegration);
});
it('should render success deploy message', () => {
expect(result.queryByTestId('deployStep-success')).toBeInTheDocument();
});
it('should report telemetry for integration complete', () => {
expect(mockReportEvent).toHaveBeenCalledWith(
TelemetryEventType.IntegrationAssistantComplete,
{
sessionId: expect.any(String),
integrationName,
integrationDescription: integrationSettings.description,
dataStreamName: integrationSettings.dataStreamName,
inputType: integrationSettings.inputType,
model: expect.any(String),
actionTypeId: connector.actionTypeId,
provider: connector.apiProvider ?? 'unknown',
durationMs: expect.any(Number),
errorMessage: undefined,
}
);
});
it('should render the save button', () => {
expect(result.queryByTestId('saveZipButton')).toBeInTheDocument();
});
describe('when save button is clicked', () => {
beforeEach(() => {
result.getByTestId('saveZipButton').click();
});
it('should save file', () => {
expect(mockSaveAs).toHaveBeenCalledWith(builtIntegration, `${integrationName}.zip`);
});
});
});
describe('when deploy fails', () => {
describe('when build integration throws errors', () => {
const errorMessage = 'build integration failed';
let result: RenderResult;
beforeEach(async () => {
mockRunBuildIntegration.mockImplementationOnce(() => {
throw new Error(errorMessage);
});
await act(async () => {
result = render(
<DeployStep
integrationSettings={integrationSettings}
connector={connector}
result={results}
/>,
{ wrapper }
);
await waitFor(() => expect(result.queryByTestId('deployStep-error')).toBeInTheDocument());
});
});
it('should not render success deploy message', () => {
expect(result.queryByTestId('deployStep-success')).not.toBeInTheDocument();
});
it('should render the error message', () => {
expect(result.queryByTestId('deployStep-error')).toBeInTheDocument();
expect(result.queryByText(errorMessage)).toBeInTheDocument();
});
it('should not render the save button', () => {
expect(result.queryByTestId('saveZipButton')).not.toBeInTheDocument();
});
it('should report telemetry for integration complete with error', () => {
expect(mockReportEvent).toHaveBeenCalledWith(
TelemetryEventType.IntegrationAssistantComplete,
{
sessionId: expect.any(String),
integrationName: integrationSettings.name,
integrationDescription: integrationSettings.description,
dataStreamName: integrationSettings.dataStreamName,
inputType: integrationSettings.inputType,
model: expect.any(String),
actionTypeId: connector.actionTypeId,
provider: connector.apiProvider ?? 'unknown',
durationMs: expect.any(Number),
errorMessage,
}
);
});
});
describe('when install integration throws errors', () => {
const errorMessage = 'install integration failed';
let result: RenderResult;
beforeEach(async () => {
mockRunInstallPackage.mockImplementationOnce(() => {
throw new Error(errorMessage);
});
await act(async () => {
result = render(
<DeployStep
integrationSettings={integrationSettings}
connector={connector}
result={results}
/>,
{ wrapper }
);
await waitFor(() => expect(result.queryByTestId('deployStep-error')).toBeInTheDocument());
});
});
it('should not render success deploy message', () => {
expect(result.queryByTestId('deployStep-success')).not.toBeInTheDocument();
});
it('should render the error message', () => {
expect(result.queryByTestId('deployStep-error')).toBeInTheDocument();
expect(result.queryByText(errorMessage)).toBeInTheDocument();
});
it('should not render the save button', () => {
expect(result.queryByTestId('saveZipButton')).not.toBeInTheDocument();
});
it('should report telemetry for integration complete with error', () => {
expect(mockReportEvent).toHaveBeenCalledWith(
TelemetryEventType.IntegrationAssistantComplete,
{
sessionId: expect.any(String),
integrationName: integrationSettings.name,
integrationDescription: integrationSettings.description,
dataStreamName: integrationSettings.dataStreamName,
inputType: integrationSettings.inputType,
model: expect.any(String),
actionTypeId: connector.actionTypeId,
provider: connector.apiProvider ?? 'unknown',
durationMs: expect.any(Number),
errorMessage,
}
);
});
});
});
});

View file

@ -47,9 +47,9 @@ export const DeployStep = React.memo<DeployStepProps>(
<EuiSpacer size="m" />
<EuiFlexGroup direction="row" justifyContent="center">
<EuiFlexItem grow={false}>
{isLoading && <EuiLoadingSpinner size="xl" />}
{isLoading && <EuiLoadingSpinner size="xl" data-test-subj="deployStep-loading" />}
{error && (
<EuiText color="danger" size="s">
<EuiText color="danger" size="s" data-test-subj="deployStep-error">
{error}
</EuiText>
)}
@ -62,7 +62,7 @@ export const DeployStep = React.memo<DeployStepProps>(
return (
<SuccessSection integrationName={integrationName}>
<EuiSpacer size="m" />
<EuiPanel hasShadow={false} hasBorder paddingSize="l">
<EuiPanel hasShadow={false} hasBorder paddingSize="l" data-test-subj="deployStep-success">
<EuiFlexGroup direction="row" alignItems="center">
<EuiFlexItem>
<EuiFlexGroup direction="row" alignItems="flexStart" justifyContent="flexStart">
@ -86,7 +86,9 @@ export const DeployStep = React.memo<DeployStepProps>(
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiLink onClick={onSaveZip}>{i18n.DOWNLOAD_ZIP_LINK}</EuiLink>
<EuiLink onClick={onSaveZip} data-test-subj="saveZipButton">
{i18n.DOWNLOAD_ZIP_LINK}
</EuiLink>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>

View file

@ -88,7 +88,18 @@ export const useDeployIntegration = ({
}
} catch (e) {
if (abortController.signal.aborted) return;
setError(`Error: ${e.body?.message ?? e.message}`);
const errorMessage = `${e.message}${
e.body ? ` (${e.body.statusCode}): ${e.body.message}` : ''
}`;
telemetry.reportAssistantComplete({
integrationName: integrationSettings.name ?? '',
integrationSettings,
connector,
error: errorMessage,
});
setError(errorMessage);
} finally {
setIsLoading(false);
}

View file

@ -0,0 +1,154 @@
/*
* 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 { render, act, type RenderResult, fireEvent, waitFor } from '@testing-library/react';
import { TestProvider } from '../../../../../mocks/test_provider';
import { IntegrationStep } from './integration_step';
import { ActionsProvider } from '../../state';
import { mockActions, mockState } from '../../mocks/state';
const integrationSettings = mockState.integrationSettings!;
const wrapper: React.FC = ({ children }) => (
<TestProvider>
<ActionsProvider value={mockActions}>{children}</ActionsProvider>
</TestProvider>
);
describe('IntegrationStep', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('when settings are undefined', () => {
let result: RenderResult;
beforeEach(() => {
result = render(<IntegrationStep integrationSettings={undefined} />, { wrapper });
});
it('should render integration step page', () => {
expect(result.queryByTestId('integrationStep')).toBeInTheDocument();
});
describe('when title changes', () => {
const title = 'My Integration';
beforeEach(() => {
act(() => {
fireEvent.change(result.getByTestId('integrationTitleInput'), {
target: { value: title },
});
});
});
it('should call setIntegrationSettings', () => {
expect(mockActions.setIntegrationSettings).toHaveBeenCalledWith({ title });
});
});
describe('when description changes', () => {
const description = 'My Integration description';
beforeEach(() => {
act(() => {
fireEvent.change(result.getByTestId('integrationDescriptionInput'), {
target: { value: description },
});
});
});
it('should call setIntegrationSettings', () => {
expect(mockActions.setIntegrationSettings).toHaveBeenCalledWith({ description });
});
});
describe('when logo is set', () => {
describe('when logo is valid', () => {
const file = new File(['(⌐□_□)'], 'test.svg', { type: 'image/svg+xml' });
beforeEach(() => {
act(() => {
fireEvent.change(result.getByTestId('integrationLogoFilePicker'), {
target: { files: [file] },
});
});
});
it('should call setIntegrationSettings', async () => {
await waitFor(() => {
expect(mockActions.setIntegrationSettings).toHaveBeenCalledWith({
logo: expect.any(String),
});
});
});
});
describe('when logo is too large', () => {
const file = new File(['(⌐□_□)'], 'test.svg', { type: 'image/svg+xml' });
Object.defineProperty(file, 'size', { value: 1024 * 1024 * 2 }); // override Blob size getter value by 2Mb
beforeEach(() => {
act(() => {
fireEvent.change(result.getByTestId('integrationLogoFilePicker'), {
target: { files: [file] },
});
});
});
it('should render logo error', async () => {
await waitFor(() => {
expect(
result.queryByText('test.svg is too large, maximum size is 1Mb.')
).toBeInTheDocument();
});
expect(mockActions.setIntegrationSettings).not.toHaveBeenCalledWith();
});
});
});
describe('when logo is removed', () => {
beforeEach(() => {
act(() => {
fireEvent.change(result.getByTestId('integrationLogoFilePicker'), {
target: { files: [] },
});
});
});
it('should call setIntegrationSettings', async () => {
await waitFor(() => {
expect(mockActions.setIntegrationSettings).toHaveBeenCalledWith({
logo: undefined,
});
});
});
});
});
describe('when settings are defined', () => {
let result: RenderResult;
beforeEach(() => {
result = render(<IntegrationStep integrationSettings={integrationSettings} />, { wrapper });
});
it('should render preview integration title', () => {
expect(result.queryByTestId('packageCardPreview')).toHaveTextContent(
integrationSettings.title!
);
});
it('should render preview integration description', () => {
expect(result.queryByTestId('packageCardPreview')).toHaveTextContent(
integrationSettings.description!
);
});
it('should render preview integration logo', () => {
expect(result.queryByTestId('packageCardPreviewIcon')).toHaveAttribute(
'data-euiicon-type',
`data:image/svg+xml;base64,${integrationSettings.logo}`
);
});
});
});

View file

@ -27,7 +27,7 @@ import { PackageCardPreview } from './package_card_preview';
import { useActions } from '../../state';
import * as i18n from './translations';
const MaxLogoSize = 1048576; // One megabyte
const MaxLogoSize = 1024 * 1024; // One megabyte
const useLayoutStyles = () => {
const { euiTheme } = useEuiTheme();
@ -92,7 +92,7 @@ export const IntegrationStep = React.memo<IntegrationStepProps>(({ integrationSe
return (
<StepContentWrapper title={i18n.TITLE} subtitle={i18n.DESCRIPTION}>
<EuiPanel paddingSize="none" hasShadow={false} hasBorder>
<EuiPanel paddingSize="none" hasShadow={false} hasBorder data-test-subj="integrationStep">
<EuiFlexGroup direction="row" gutterSize="none">
<EuiFlexItem css={styles.left}>
<EuiForm component="form" fullWidth>
@ -101,6 +101,7 @@ export const IntegrationStep = React.memo<IntegrationStepProps>(({ integrationSe
name="title"
value={integrationSettings?.title ?? ''}
onChange={onChange.title}
data-test-subj="integrationTitleInput"
/>
</EuiFormRow>
<EuiFormRow label={i18n.DESCRIPTION_LABEL}>
@ -108,6 +109,7 @@ export const IntegrationStep = React.memo<IntegrationStepProps>(({ integrationSe
name="description"
value={integrationSettings?.description ?? ''}
onChange={onChange.description}
data-test-subj="integrationDescriptionInput"
/>
</EuiFormRow>
<EuiFormRow label={i18n.LOGO_LABEL}>
@ -120,6 +122,7 @@ export const IntegrationStep = React.memo<IntegrationStepProps>(({ integrationSe
aria-label="Upload an svg logo image"
accept="image/svg+xml"
isInvalid={logoError != null}
data-test-subj="integrationLogoFilePicker"
/>
<EuiSpacer size="xs" />
{logoError && (

View file

@ -40,7 +40,7 @@ export const PackageCardPreview = React.memo<PackageCardPreviewProps>(({ integra
return (
<EuiCard
css={cardCss}
data-test-subj="package-card-preview"
data-test-subj="packageCardPreview"
layout="horizontal"
title={integrationSettings?.title ?? ''}
description={integrationSettings?.description ?? ''}
@ -49,6 +49,7 @@ export const PackageCardPreview = React.memo<PackageCardPreviewProps>(({ integra
icon={
<EuiIcon
size={'xl'}
data-test-subj="packageCardPreviewIcon"
type={
integrationSettings?.logo
? `data:image/svg+xml;base64,${integrationSettings.logo}`

View file

@ -0,0 +1,176 @@
/*
* 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 { render, act, type RenderResult, fireEvent, waitFor } from '@testing-library/react';
import { TestProvider } from '../../../../../mocks/test_provider';
import { ReviewStep } from './review_step';
import { ActionsProvider } from '../../state';
import { mockActions, mockState } from '../../mocks/state';
const integrationSettings = mockState.integrationSettings!;
const results = mockState.result!;
const mockResults = {
pipeline: { test: 'checkPipelineResponse' },
docs: [{ id: 'testDoc' }],
};
const customPipeline = {
...mockResults.pipeline,
description: 'testing',
};
const defaultRequest = {
pipeline: customPipeline,
rawSamples: integrationSettings.logsSampleParsed!,
};
const mockRunCheckPipelineResults = jest.fn((_: unknown) => ({ results: mockResults }));
jest.mock('../../../../../common/lib/api', () => ({
runCheckPipelineResults: (params: unknown) => mockRunCheckPipelineResults(params),
}));
jest.mock('@kbn/code-editor', () => ({
...jest.requireActual('@kbn/code-editor'),
CodeEditor: (props: { value: string; onChange: Function }) => (
<input
data-test-subj={'mockCodeEditor'}
value={props.value}
onChange={(e) => {
props.onChange(e.target.value);
}}
/>
),
}));
const wrapper: React.FC = ({ children }) => (
<TestProvider>
<ActionsProvider value={mockActions}>{children}</ActionsProvider>
</TestProvider>
);
describe('ReviewStep', () => {
let result: RenderResult;
beforeEach(() => {
jest.clearAllMocks();
result = render(
<ReviewStep
integrationSettings={integrationSettings}
isGenerating={false}
result={results}
/>,
{ wrapper }
);
});
it('should render integration step page', () => {
expect(result.queryByTestId('reviewStep')).toBeInTheDocument();
});
describe('when edit pipeline button is clicked', () => {
beforeEach(() => {
act(() => {
result.getByTestId('editPipelineButton').click();
});
});
it('should open pipeline editor', () => {
expect(result.queryByTestId('mockCodeEditor')).toBeInTheDocument();
});
describe('when saving pipeline without changes', () => {
beforeEach(() => {
act(() => {
result.getByTestId('savePipelineButton').click();
});
});
it('should call setResults', () => {
expect(mockActions.setResult).not.toHaveBeenCalled();
});
});
describe('when saving pipeline with changes', () => {
beforeEach(async () => {
act(() => {
fireEvent.change(result.getByTestId('mockCodeEditor'), {
target: { value: JSON.stringify(customPipeline) },
});
});
});
describe('when check pipeline is successful', () => {
beforeEach(async () => {
await act(async () => {
result.getByTestId('savePipelineButton').click();
await waitFor(() => expect(mockActions.setIsGenerating).toHaveBeenCalledWith(false));
});
});
it('should call setIsGenerating', () => {
expect(mockActions.setIsGenerating).toHaveBeenCalledWith(true);
});
it('should check pipeline', () => {
expect(mockRunCheckPipelineResults).toHaveBeenCalledWith(defaultRequest);
});
it('should call setResults', () => {
expect(mockActions.setResult).toHaveBeenCalledWith({
...mockResults,
pipeline: customPipeline,
});
});
});
describe('when check pipeline fails', () => {
beforeEach(async () => {
mockRunCheckPipelineResults.mockImplementationOnce(() => {
throw new Error('test error');
});
await act(async () => {
result.getByTestId('savePipelineButton').click();
await waitFor(() => expect(mockActions.setIsGenerating).toHaveBeenCalledWith(false));
});
});
it('should check pipeline', () => {
expect(mockRunCheckPipelineResults).toHaveBeenCalledWith(defaultRequest);
});
it('should not call setResults', () => {
expect(mockActions.setResult).not.toHaveBeenCalled();
});
it('should show error', () => {
expect(result.queryByText('Error: test error')).toBeInTheDocument();
});
});
describe('when check pipeline has no docs', () => {
beforeEach(async () => {
mockRunCheckPipelineResults.mockReturnValueOnce({
results: { ...mockResults, docs: [] },
});
await act(async () => {
result.getByTestId('savePipelineButton').click();
await waitFor(() => expect(mockActions.setIsGenerating).toHaveBeenCalledWith(false));
});
});
it('should check pipeline', () => {
expect(mockRunCheckPipelineResults).toHaveBeenCalledWith(defaultRequest);
});
it('should not call setResults', () => {
expect(mockActions.setResult).not.toHaveBeenCalled();
});
it('should show error', () => {
expect(result.queryByText('No results for the pipeline')).toBeInTheDocument();
});
});
});
});
});

View file

@ -73,12 +73,15 @@ export const ReviewStep = React.memo<ReviewStepProps>(
title={i18n.TITLE}
subtitle={i18n.DESCRIPTION}
right={
<EuiButton onClick={() => setIsPipelineEditionVisible(true)}>
<EuiButton
onClick={() => setIsPipelineEditionVisible(true)}
data-test-subj="editPipelineButton"
>
{i18n.EDIT_PIPELINE_BUTTON}
</EuiButton>
}
>
<EuiPanel hasShadow={false} hasBorder>
<EuiPanel hasShadow={false} hasBorder data-test-subj="reviewStep">
{isGenerating ? (
<EuiLoadingSpinner size="l" />
) : (
@ -106,7 +109,7 @@ export const ReviewStep = React.memo<ReviewStepProps>(
responsive={false}
css={{ height: '100%' }}
>
<EuiFlexItem grow={true} data-test-subj="inspectorRequestCodeViewerContainer">
<EuiFlexItem grow={true}>
<CodeEditor
languageId={XJsonLang.ID}
value={JSON.stringify(result?.pipeline, null, 2)}
@ -129,7 +132,11 @@ export const ReviewStep = React.memo<ReviewStepProps>(
<EuiFlyoutFooter>
<EuiFlexGroup direction="row" gutterSize="none" justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<EuiButton fill onClick={saveCustomPipeline}>
<EuiButton
fill
onClick={saveCustomPipeline}
data-test-subj="savePipelineButton"
>
{i18n.SAVE_BUTTON}
</EuiButton>
</EuiFlexItem>

View file

@ -42,19 +42,19 @@ export const useCheckPipeline = ({ integrationSettings, customPipeline }: CheckP
};
setIsGenerating(true);
const ecsGraphResult = await runCheckPipelineResults(parameters, deps);
const checkPipelineResults = await runCheckPipelineResults(parameters, deps);
if (abortController.signal.aborted) return;
if (isEmpty(ecsGraphResult?.results.docs)) {
if (isEmpty(checkPipelineResults?.results.docs)) {
setError('No results for the pipeline');
return;
}
setResult({
pipeline: customPipeline,
docs: ecsGraphResult.results.docs,
docs: checkPipelineResults.results.docs,
});
} catch (e) {
if (abortController.signal.aborted) return;
setError(`Error: ${e.body.message ?? e.message}`);
setError(`Error: ${e.body?.message ?? e.message}`);
} finally {
setIsGenerating(false);
}

View file

@ -39,6 +39,7 @@ type ReportAssistantComplete = (params: {
integrationName: string;
integrationSettings: IntegrationSettings;
connector: AIConnector;
error?: string;
}) => void;
interface TelemetryContextProps {
@ -113,7 +114,7 @@ export const TelemetryContextProvider = React.memo<PropsWithChildren<{}>>(({ chi
);
const reportAssistantComplete = useCallback<ReportAssistantComplete>(
({ integrationName, integrationSettings, connector }) => {
({ integrationName, integrationSettings, connector, error }) => {
telemetry.reportEvent(TelemetryEventType.IntegrationAssistantComplete, {
sessionId: sessionData.current.sessionId,
integrationName,
@ -124,6 +125,7 @@ export const TelemetryContextProvider = React.memo<PropsWithChildren<{}>>(({ chi
model: getConnectorModel(connector),
provider: connector.apiProvider ?? 'unknown',
durationMs: Date.now() - sessionData.current.startedAt,
errorMessage: error,
});
},
[telemetry]

View file

@ -0,0 +1,76 @@
/*
* 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 { render } from '@testing-library/react';
import { TestProvider } from '../../mocks/test_provider';
import { CreateIntegrationCardButton } from './create_integration_card_button';
import { mockServices } from '../../services/mocks/services';
const renderOptions = { wrapper: TestProvider };
describe('CreateIntegrationCardButton', () => {
describe('when not compressed (default)', () => {
let element: React.ReactElement;
beforeEach(() => {
element = <CreateIntegrationCardButton />;
});
it('should render the link button', () => {
const result = render(element, renderOptions);
expect(result.queryByTestId('createIntegrationLink')).toBeInTheDocument();
});
it('should render the sub-title', () => {
const result = render(element, renderOptions);
expect(
result.queryByText('Create a custom one to fit your requirements')
).toBeInTheDocument();
});
it('should navigate when button clicked', () => {
const url = '/app/integrations';
mockServices.application.getUrlForApp.mockReturnValueOnce(url);
const result = render(element, renderOptions);
result.queryByTestId('createIntegrationLink')?.click();
expect(mockServices.application.navigateToUrl).toHaveBeenCalledWith(url);
});
});
describe('when compressed', () => {
let element: React.ReactElement;
beforeEach(() => {
element = <CreateIntegrationCardButton compressed />;
});
it('should render the link button', () => {
const result = render(element, renderOptions);
expect(result.queryByTestId('createIntegrationLink')).toBeInTheDocument();
});
it('should not render the sub-title', () => {
const result = render(element, renderOptions);
expect(
result.queryByText('Create a custom one to fit your requirements')
).not.toBeInTheDocument();
});
it('should navigate when button clicked', () => {
const url = '/app/integrations';
mockServices.application.getUrlForApp.mockReturnValueOnce(url);
const result = render(element, renderOptions);
result.queryByTestId('createIntegrationLink')?.click();
expect(mockServices.application.navigateToUrl).toHaveBeenCalledWith(url);
});
});
});

View file

@ -93,7 +93,12 @@ export const CreateIntegrationCardButton = React.memo<CreateIntegrationCardButto
</EuiFlexItem>
<EuiFlexItem grow={false}>
{/* eslint-disable-next-line @elastic/eui/href-or-on-click */}
<EuiLink color="primary" href={href} onClick={navigate}>
<EuiLink
color="primary"
href={href}
onClick={navigate}
data-test-subj="createIntegrationLink"
>
<EuiFlexGroup
justifyContent="center"
alignItems="center"

View file

@ -0,0 +1,23 @@
/*
* 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 { PropsWithChildren } from 'react';
import React from 'react';
import { I18nProvider } from '@kbn/i18n-react';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import { mockServices } from '../services/mocks/services';
import { TelemetryContextProvider } from '../components/create_integration/telemetry';
export const TestProvider: React.FC<PropsWithChildren<{}>> = ({ children }) => {
return (
<I18nProvider>
<KibanaContextProvider services={mockServices}>
<TelemetryContextProvider>{children}</TelemetryContextProvider>
</KibanaContextProvider>
</I18nProvider>
);
};

View file

@ -4,17 +4,5 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { Observable } from 'rxjs';
import type { CoreStart } from '@kbn/core/public';
import type { IntegrationAssistantPluginStartDependencies } from '../types';
import type { TelemetryService } from './telemetry/service';
export * from './types';
export { Telemetry } from './telemetry/service';
export type RenderUpselling = React.ReactNode;
export type Services = CoreStart &
IntegrationAssistantPluginStartDependencies & {
telemetry: TelemetryService;
renderUpselling$: Observable<RenderUpselling | undefined>;
};

View file

@ -0,0 +1,20 @@
/*
* 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 { of } from 'rxjs';
import { coreMock } from '@kbn/core/public/mocks';
import { triggersActionsUiMock } from '@kbn/triggers-actions-ui-plugin/public/mocks';
import { licensingMock } from '@kbn/licensing-plugin/public/mocks';
import { telemetryServiceMock } from '../telemetry/mocks/service';
export const mockServices = {
...coreMock.createStart(),
triggersActionsUi: triggersActionsUiMock.createStart(),
licensing: licensingMock.createStart(),
telemetry: telemetryServiceMock.createStart(),
renderUpselling$: of(undefined),
};

View file

@ -0,0 +1,14 @@
/*
* 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.
*/
export const mockReportEvent = jest.fn();
export const telemetryServiceMock = {
createStart: () => ({
reportEvent: mockReportEvent,
}),
};

View file

@ -5,8 +5,14 @@
* 2.0.
*/
import type { CoreStart } from '@kbn/core/public';
import type { Observable } from 'rxjs';
import type { IntegrationAssistantPluginStartDependencies } from '../types';
import type { TelemetryService } from './telemetry/service';
export type RenderUpselling = React.ReactNode;
export type Services = CoreStart &
IntegrationAssistantPluginStartDependencies & { telemetry: TelemetryService };
IntegrationAssistantPluginStartDependencies & {
telemetry: TelemetryService;
renderUpselling$: Observable<RenderUpselling | undefined>;
};