diff --git a/x-pack/plugins/aiops/public/components/change_point_detection/change_point_detection_page.tsx b/x-pack/plugins/aiops/public/components/change_point_detection/change_point_detection_page.tsx index 98ab86190dd1..15f618946cb8 100644 --- a/x-pack/plugins/aiops/public/components/change_point_detection/change_point_detection_page.tsx +++ b/x-pack/plugins/aiops/public/components/change_point_detection/change_point_detection_page.tsx @@ -121,6 +121,7 @@ export const ChangePointDetectionPage: FC = () => { onClick={() => setFlyoutVisible(!isFlyoutVisible)} size={'s'} disabled={!hasSelectedChangePoints} + data-test-subj={'aiopsChangePointDetectionViewSelected'} > { onClose={setFlyoutVisible.bind(null, false)} aria-labelledby={'change_point_charts'} size={'l'} + data-test-subj={'aiopsChangePointDetectionSelectedCharts'} > diff --git a/x-pack/plugins/aiops/public/components/change_point_detection/change_points_table.tsx b/x-pack/plugins/aiops/public/components/change_point_detection/change_points_table.tsx index dd054b88076e..905a76fe90e9 100644 --- a/x-pack/plugins/aiops/public/components/change_point_detection/change_points_table.tsx +++ b/x-pack/plugins/aiops/public/components/change_point_detection/change_points_table.tsx @@ -94,7 +94,9 @@ export const ChangePointsTable: FC = ({ const columns: Array> = [ { + id: 'timestamp', field: 'timestamp', + 'data-test-subj': 'aiopsChangePointTimestamp', name: i18n.translate('xpack.aiops.changePointDetection.timeColumn', { defaultMessage: 'Time', }), @@ -104,6 +106,8 @@ export const ChangePointsTable: FC = ({ render: (timestamp: ChangePointAnnotation['timestamp']) => dateFormatter.convert(timestamp), }, { + id: 'preview', + 'data-test-subj': 'aiopsChangePointPreview', name: i18n.translate('xpack.aiops.changePointDetection.previewColumn', { defaultMessage: 'Preview', }), @@ -118,6 +122,8 @@ export const ChangePointsTable: FC = ({ }, }, { + id: 'type', + 'data-test-subj': 'aiopsChangePointType', field: 'type', name: i18n.translate('xpack.aiops.changePointDetection.typeColumn', { defaultMessage: 'Type', @@ -127,6 +133,8 @@ export const ChangePointsTable: FC = ({ render: (type: ChangePointAnnotation['type']) => {type}, }, { + id: 'pValue', + 'data-test-subj': 'aiopsChangePointPValue', field: 'p_value', name: ( = ({ ...(fieldConfig.splitField ? [ { + id: 'groupName', + 'data-test-subj': 'aiopsChangePointGroupName', field: 'group.name', name: i18n.translate('xpack.aiops.changePointDetection.fieldNameColumn', { defaultMessage: 'Field name', @@ -160,6 +170,8 @@ export const ChangePointsTable: FC = ({ truncateText: false, }, { + id: 'groupValue', + 'data-test-subj': 'aiopsChangePointGroupValue', field: 'group.value', name: i18n.translate('xpack.aiops.changePointDetection.fieldValueColumn', { defaultMessage: 'Field value', @@ -218,7 +230,7 @@ export const ChangePointsTable: FC = ({ ); }, isPrimary: true, - 'data-test-subj': 'aiopsChangePointFilterForValue', + 'data-test-subj': 'aiopsChangePointFilterOutValue', }, ] as Array>, }, @@ -247,11 +259,15 @@ export const ChangePointsTable: FC = ({ itemId={'id'} selection={selectionValue} loading={isLoading} + data-test-subj={`aiopsChangePointResultsTable ${isLoading ? 'loading' : 'loaded'}`} items={annotations} columns={columns} pagination={{ pageSizeOptions: [5, 10, 15] }} sorting={defaultSorting} hasActions={hasActions} + rowProps={(item) => ({ + 'data-test-subj': `aiopsChangePointResultsTableRow row-${item.id}`, + })} message={ isLoading ? ( = ({ fieldConfig, annotat }); return ( -
+
{ return ( onChange(value, index)} onRemove={onRemove.bind(null, index)} @@ -105,7 +106,11 @@ export const FieldsConfig: FC = () => { ); })} - = MAX_CHANGE_POINT_CONFIGS}> + = MAX_CHANGE_POINT_CONFIGS} + data-test-subj={'aiopsChangePointAddConfig'} + > void; onRemove: () => void; onSelectionChange: (update: SelectedChangePoint[]) => void; + 'data-test-subj': string; } /** @@ -137,6 +143,7 @@ const FieldPanel: FC = ({ onRemove, removeDisabled, onSelectionChange, + 'data-test-subj': dataTestSubj, }) => { const { combinedQuery, requestParams } = useChangePointDetectionContext(); @@ -151,7 +158,7 @@ const FieldPanel: FC = ({ } = useChangePointResults(fieldConfig, requestParams, combinedQuery, splitFieldCardinality); return ( - + diff --git a/x-pack/test/functional/apps/aiops/change_point_detection.ts b/x-pack/test/functional/apps/aiops/change_point_detection.ts new file mode 100644 index 000000000000..d93409c31090 --- /dev/null +++ b/x-pack/test/functional/apps/aiops/change_point_detection.ts @@ -0,0 +1,95 @@ +/* + * 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 expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getPageObjects, getService }: FtrProviderContext) { + const elasticChart = getService('elasticChart'); + const esArchiver = getService('esArchiver'); + const aiops = getService('aiops'); + + // aiops lives in the ML UI so we need some related services. + const ml = getService('ml'); + + describe('change point detection', async function () { + before(async () => { + await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/ecommerce'); + await ml.testResources.createIndexPatternIfNeeded('ft_ecommerce', 'order_date'); + await ml.testResources.setKibanaTimeZoneToUTC(); + await ml.securityUI.loginAsMlPowerUser(); + }); + + after(async () => { + await ml.testResources.deleteIndexPatternByTitle('ft_ecommerce'); + }); + + it(`loads the change point detection page`, async () => { + // Start navigation from the base of the ML app. + await ml.navigation.navigateToMl(); + await elasticChart.setNewChartUiDebugFlag(true); + await aiops.changePointDetectionPage.navigateToIndexPatternSelection(); + await ml.jobSourceSelection.selectSourceForChangePointDetection('ft_ecommerce'); + await aiops.changePointDetectionPage.assertChangePointDetectionPageExists(); + }); + + it('detects a change point when no split field is selected', async () => { + await aiops.changePointDetectionPage.clickUseFullDataButton(); + await aiops.changePointDetectionPage.selectMetricField(0, 'products.discount_amount'); + const result = await aiops.changePointDetectionPage.getTable(0).parseTable(); + expect(result.length).to.eql(1); + expect(parseInt(result[0].pValue, 10)).to.eql(0); + expect(result[0].type).to.eql('distribution_change'); + + await elasticChart.waitForRenderComplete('aiopChangePointPreviewChart > xyVisChart'); + const chartState = await elasticChart.getChartDebugData( + 'aiopChangePointPreviewChart > xyVisChart', + 0, + 5000 + ); + if (!chartState) { + throw new Error('Preview chart debug state is not available'); + } + expect(chartState.annotations![0].data.details).to.eql('distribution_change'); + expect(chartState.annotations![0].domainType).to.eql('xDomain'); + expect(chartState.lines![0].points.length).to.be.above(30); + }); + + it('shows multiple results when split field is selected', async () => { + await aiops.changePointDetectionPage.clickUseFullDataButton(); + await aiops.changePointDetectionPage.selectMetricField(0, 'products.discount_amount'); + await aiops.changePointDetectionPage.selectSplitField(0, 'geoip.city_name'); + const result = await aiops.changePointDetectionPage.getTable(0).parseTable(); + expect(result.length).to.eql(5); + // assert asc sorting by p_value is applied + expect(parseFloat(result[0].pValue)).to.be.lessThan(parseFloat(result[4].pValue)); + }); + + it('allows change point selection for detailed view', async () => { + await aiops.changePointDetectionPage.getTable(0).selectAllRows(); + await aiops.changePointDetectionPage.viewSelected(); + await aiops.changePointDetectionPage.assertDetailedView(5); + await aiops.changePointDetectionPage.closeFlyout(); + // deselect + await aiops.changePointDetectionPage.getTable(0).selectAllRows(); + }); + + it('supports a quick filter actions', async () => { + await aiops.changePointDetectionPage + .getTable(0) + .invokeAction(0, 'aiopsChangePointFilterForValue'); + const resultFor = await aiops.changePointDetectionPage.getTable(0).parseTable(); + expect(resultFor.length).to.eql(1); + }); + + it('supports multiple configurations for change point detection', async () => { + await aiops.changePointDetectionPage.assertPanelExist(0); + await aiops.changePointDetectionPage.addChangePointConfig(); + await aiops.changePointDetectionPage.assertPanelExist(1); + }); + }); +} diff --git a/x-pack/test/functional/apps/aiops/index.ts b/x-pack/test/functional/apps/aiops/index.ts index 6bcda1312200..2fb0f84c990c 100644 --- a/x-pack/test/functional/apps/aiops/index.ts +++ b/x-pack/test/functional/apps/aiops/index.ts @@ -30,5 +30,6 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { }); loadTestFile(require.resolve('./explain_log_rate_spikes')); + loadTestFile(require.resolve('./change_point_detection')); }); } diff --git a/x-pack/test/functional/services/aiops/change_point_detection_page.ts b/x-pack/test/functional/services/aiops/change_point_detection_page.ts new file mode 100644 index 000000000000..2eb539ef4fc7 --- /dev/null +++ b/x-pack/test/functional/services/aiops/change_point_detection_page.ts @@ -0,0 +1,145 @@ +/* + * 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 expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; +import { MlTableService } from '../ml/common_table_service'; + +export function ChangePointDetectionPageProvider( + { getService, getPageObject }: FtrProviderContext, + tableService: MlTableService +) { + const retry = getService('retry'); + const testSubjects = getService('testSubjects'); + const comboBox = getService('comboBox'); + const browser = getService('browser'); + const elasticChart = getService('elasticChart'); + + return { + async navigateToIndexPatternSelection() { + await testSubjects.click('mlMainTab changePointDetection'); + await testSubjects.existOrFail('mlPageSourceSelection'); + }, + + async assertChangePointDetectionPageExists() { + await retry.tryForTime(30 * 1000, async () => { + await testSubjects.existOrFail('aiopsChangePointDetectionPage'); + }); + }, + + async assertQueryInput(expectedQueryString: string) { + const aiopsQueryInput = await testSubjects.find('aiopsQueryInput'); + const actualQueryString = await aiopsQueryInput.getVisibleText(); + expect(actualQueryString).to.eql( + expectedQueryString, + `Expected query bar text to be '${expectedQueryString}' (got '${actualQueryString}')` + ); + }, + + async assertPanelLoaded() { + await retry.tryForTime(30 * 1000, async () => { + await testSubjects.waitForHidden('aiopsChangePointResultsTable loading'); + }); + }, + + async assertMetricFieldSelection(panelIndex: number = 0, expectedIdentifier: string[]) { + const comboBoxSelectedOptions = await comboBox.getComboBoxSelectedOptions( + `aiopsChangePointPanel_${panelIndex} > aiopsChangePointMetricField > comboBoxInput` + ); + expect(comboBoxSelectedOptions).to.eql( + expectedIdentifier, + `Expected a metric field to be '${expectedIdentifier}' (got '${comboBoxSelectedOptions}')` + ); + }, + + async selectMetricField(panelIndex: number = 0, value: string) { + await comboBox.set( + `aiopsChangePointPanel_${panelIndex} > aiopsChangePointMetricField > comboBoxInput`, + value + ); + await this.assertMetricFieldSelection(panelIndex, [value]); + }, + + async assertSplitFieldSelection(panelIndex: number = 0, expectedIdentifier: string[]) { + const comboBoxSelectedOptions = await comboBox.getComboBoxSelectedOptions( + `aiopsChangePointPanel_${panelIndex} > aiopsChangePointSplitField > comboBoxInput` + ); + expect(comboBoxSelectedOptions).to.eql( + expectedIdentifier, + `Expected a split field to be '${expectedIdentifier}' (got '${comboBoxSelectedOptions}')` + ); + }, + + async selectSplitField(panelIndex: number = 0, value: string) { + await comboBox.set( + `aiopsChangePointPanel_${panelIndex} > aiopsChangePointSplitField > comboBoxInput`, + value + ); + await this.assertSplitFieldSelection(panelIndex, [value]); + }, + + async clickUseFullDataButton() { + await retry.tryForTime(30 * 1000, async () => { + await testSubjects.clickWhenNotDisabledWithoutRetry('mlDatePickerButtonUseFullData'); + await testSubjects.clickWhenNotDisabledWithoutRetry('superDatePickerApplyTimeButton'); + await testSubjects.existOrFail('aiopsChangePointResultsTable loaded'); + }); + }, + + async viewSelected() { + await retry.tryForTime(30 * 1000, async () => { + await testSubjects.clickWhenNotDisabledWithoutRetry( + 'aiopsChangePointDetectionViewSelected' + ); + await testSubjects.existOrFail('aiopsChangePointDetectionSelectedCharts'); + }); + }, + + async assertDetailedView(expectedChartCount: number) { + const testSubj = 'aiopsChangePointDetectionSelectedCharts > xyVisChart'; + await elasticChart.waitForRenderComplete(testSubj); + const changePointCharts = await testSubjects.findAll(testSubj); + expect(changePointCharts.length).to.eql( + expectedChartCount, + `Expected ${expectedChartCount} charts in the flyout (got '${changePointCharts.length}')` + ); + }, + + async closeFlyout() { + await browser.pressKeys(browser.keys.ESCAPE); + await testSubjects.missingOrFail('aiopsChangePointDetectionSelectedCharts'); + }, + + async addChangePointConfig() { + await testSubjects.click('aiopsChangePointAddConfig'); + }, + + async assertPanelExist(index: number) { + await retry.tryForTime(30 * 1000, async () => { + await testSubjects.existOrFail(`aiopsChangePointPanel_${index}`); + }); + }, + + getTable(index: number) { + return tableService.getServiceInstance( + 'ChangePointResultsTable', + `aiopsChangePointResultsTable`, + 'aiopsChangePointResultsTableRow', + [ + { id: 'timestamp', testSubj: 'aiopsChangePointTimestamp' }, + { id: 'preview', testSubj: 'aiopsChangePointPreview' }, + { id: 'type', testSubj: 'aiopsChangePointType' }, + { id: 'pValue', testSubj: 'aiopsChangePointPValue' }, + { id: 'groupName', testSubj: 'aiopsChangePointGroupName' }, + { id: 'groupValue', testSubj: 'aiopsChangePointGroupValue' }, + ], + '', + `aiopsChangePointPanel_${index}` + ); + }, + }; +} diff --git a/x-pack/test/functional/services/aiops/index.ts b/x-pack/test/functional/services/aiops/index.ts index 8c208f182f3b..1edd17a7ba7d 100644 --- a/x-pack/test/functional/services/aiops/index.ts +++ b/x-pack/test/functional/services/aiops/index.ts @@ -12,6 +12,8 @@ import { ExplainLogRateSpikesAnalysisTableProvider } from './explain_log_rate_sp import { ExplainLogRateSpikesAnalysisGroupsTableProvider } from './explain_log_rate_spikes_analysis_groups_table'; import { ExplainLogRateSpikesDataGeneratorProvider } from './explain_log_rate_spikes_data_generator'; import { LogPatternAnalysisPageProvider } from './log_pattern_analysis_page'; +import { ChangePointDetectionPageProvider } from './change_point_detection_page'; +import { MlTableServiceProvider } from '../ml/common_table_service'; export function AiopsProvider(context: FtrProviderContext) { const explainLogRateSpikesPage = ExplainLogRateSpikesPageProvider(context); @@ -21,7 +23,12 @@ export function AiopsProvider(context: FtrProviderContext) { const explainLogRateSpikesDataGenerator = ExplainLogRateSpikesDataGeneratorProvider(context); const logPatternAnalysisPageProvider = LogPatternAnalysisPageProvider(context); + const tableService = MlTableServiceProvider(context); + + const changePointDetectionPage = ChangePointDetectionPageProvider(context, tableService); + return { + changePointDetectionPage, explainLogRateSpikesPage, explainLogRateSpikesAnalysisTable, explainLogRateSpikesAnalysisGroupsTable, diff --git a/x-pack/test/functional/services/ml/common_table_service.ts b/x-pack/test/functional/services/ml/common_table_service.ts index 063c6095c85b..653df1770905 100644 --- a/x-pack/test/functional/services/ml/common_table_service.ts +++ b/x-pack/test/functional/services/ml/common_table_service.ts @@ -21,7 +21,8 @@ export function MlTableServiceProvider({ getPageObject, getService }: FtrProvide public readonly tableTestSubj: string, public readonly tableRowSubj: string, public readonly columns: Array<{ id: string; testSubj: string }>, - public readonly searchInputSubj: string + public readonly searchInputSubj: string, + public readonly parentSubj?: string ) {} public async assertTableLoaded() { @@ -33,7 +34,9 @@ export function MlTableServiceProvider({ getPageObject, getService }: FtrProvide } public async parseTable() { - const table = await testSubjects.find(`~${this.tableTestSubj}`); + const table = await testSubjects.find( + `${this.parentSubj ? `${this.parentSubj} > ` : ''}~${this.tableTestSubj}` + ); const $ = await table.parseDomContent(); const rows = []; @@ -138,6 +141,28 @@ export function MlTableServiceProvider({ getPageObject, getService }: FtrProvide await this.assertTableSorting(columnName, columnIndex, direction); }); } + + public async invokeAction(rowIndex: number, actionSubject: string) { + const rows = await testSubjects.findAll( + `${this.parentSubj ? `${this.parentSubj} > ` : ''}~${this.tableTestSubj} > ~${ + this.tableRowSubj + }` + ); + + const requestedRow = rows[rowIndex]; + const actionButton = await requestedRow.findByTestSubject(actionSubject); + + await retry.tryForTime(5000, async () => { + await actionButton.click(); + await this.waitForTableToLoad(); + }); + } + + public async selectAllRows() { + await testSubjects.click( + `${this.parentSubj ? `${this.parentSubj} > ` : ''} > checkboxSelectAll` + ); + } }; return { @@ -146,10 +171,11 @@ export function MlTableServiceProvider({ getPageObject, getService }: FtrProvide tableTestSubj: string, tableRowSubj: string, columns: Array<{ id: string; testSubj: string }>, - searchInputSubj: string + searchInputSubj: string, + parentSubj?: string ) { Object.defineProperty(TableService, 'name', { value: name }); - return new TableService(tableTestSubj, tableRowSubj, columns, searchInputSubj); + return new TableService(tableTestSubj, tableRowSubj, columns, searchInputSubj, parentSubj); }, }; } diff --git a/x-pack/test/functional/services/ml/job_source_selection.ts b/x-pack/test/functional/services/ml/job_source_selection.ts index 95a2bf0dbab7..e671b294c580 100644 --- a/x-pack/test/functional/services/ml/job_source_selection.ts +++ b/x-pack/test/functional/services/ml/job_source_selection.ts @@ -46,5 +46,9 @@ export function MachineLearningJobSourceSelectionProvider({ getService }: FtrPro async selectSourceForExplainLogRateSpikes(sourceName: string) { await this.selectSource(sourceName, 'aiopsExplainLogRateSpikesPage'); }, + + async selectSourceForChangePointDetection(sourceName: string) { + await this.selectSource(sourceName, 'aiopsChangePointDetectionPage'); + }, }; }