From 0f73b57cfd0a723b8d1ad137595f585c6f3d1d97 Mon Sep 17 00:00:00 2001 From: Walter Rafelsberger Date: Thu, 28 Oct 2021 14:18:53 +0200 Subject: [PATCH] [ML] Anomaly Detection: Functional tests for anomaly detection forecasts. (#116140) Functional tests for anomaly detection forecasts. --- .../forecasting_modal/forecasts_list.js | 7 + .../timeseries_chart/timeseries_chart.js | 15 ++- .../timeseriesexplorer/timeseriesexplorer.js | 10 +- .../apps/ml/anomaly_detection/forecasts.ts | 116 ++++++++++++++++ .../apps/ml/anomaly_detection/index.ts | 1 + .../apps/ml/permissions/full_ml_access.ts | 10 +- .../apps/ml/permissions/read_ml_access.ts | 10 +- .../test/functional/services/ml/forecast.ts | 126 ++++++++++++++++++ x-pack/test/functional/services/ml/index.ts | 3 + .../services/ml/single_metric_viewer.ts | 40 ------ 10 files changed, 282 insertions(+), 56 deletions(-) create mode 100644 x-pack/test/functional/apps/ml/anomaly_detection/forecasts.ts create mode 100644 x-pack/test/functional/services/ml/forecast.ts diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/forecasting_modal/forecasts_list.js b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/forecasting_modal/forecasts_list.js index 1854982c8db0..7f9fcc7bc551 100644 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/forecasting_modal/forecasts_list.js +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/forecasting_modal/forecasts_list.js @@ -78,6 +78,12 @@ function getColumns(viewForecast) { // TODO - add in ml-info-icon to the h3 element, // then remove tooltip and inline style. export function ForecastsList({ forecasts, viewForecast }) { + const getRowProps = (item) => { + return { + 'data-test-subj': `mlForecastsListRow row-${item.rowId}`, + }; + }; + return (

); diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.js b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.js index 87131583e44e..cad5bb68fb62 100644 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.js +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.js @@ -547,9 +547,18 @@ class TimeseriesChartIntl extends Component { // Create the path elements for the forecast value line and bounds area. if (contextForecastData) { - fcsGroup.append('path').attr('class', 'area forecast'); - fcsGroup.append('path').attr('class', 'values-line forecast'); - fcsGroup.append('g').attr('class', 'focus-chart-markers forecast'); + fcsGroup + .append('path') + .attr('class', 'area forecast') + .attr('data-test-subj', 'mlForecastArea'); + fcsGroup + .append('path') + .attr('class', 'values-line forecast') + .attr('data-test-subj', 'mlForecastValuesline'); + fcsGroup + .append('g') + .attr('class', 'focus-chart-markers forecast') + .attr('data-test-subj', 'mlForecastMarkers'); } fcsGroup diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js index 9b8770350909..e4d7fc457de0 100644 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js @@ -1170,9 +1170,13 @@ export class TimeSeriesExplorer extends React.Component { + {i18n.translate('xpack.ml.timeSeriesExplorer.showForecastLabel', { + defaultMessage: 'show forecast', + })} + + } checked={showForecast} onChange={this.toggleShowForecastHandler} /> diff --git a/x-pack/test/functional/apps/ml/anomaly_detection/forecasts.ts b/x-pack/test/functional/apps/ml/anomaly_detection/forecasts.ts new file mode 100644 index 000000000000..f65653e2c03c --- /dev/null +++ b/x-pack/test/functional/apps/ml/anomaly_detection/forecasts.ts @@ -0,0 +1,116 @@ +/* + * 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 { FtrProviderContext } from '../../../ftr_provider_context'; +import { Job, Datafeed } from '../../../../../plugins/ml/common/types/anomaly_detection_jobs'; + +// @ts-expect-error not full interface +const JOB_CONFIG: Job = { + job_id: `fq_single_1_smv`, + description: 'count() on farequote dataset with 15m bucket span', + groups: ['farequote', 'automated', 'single-metric'], + analysis_config: { + bucket_span: '15m', + influencers: [], + detectors: [ + { + function: 'count', + }, + ], + }, + data_description: { time_field: '@timestamp' }, + analysis_limits: { model_memory_limit: '10mb' }, + model_plot_config: { enabled: true }, +}; + +// @ts-expect-error not full interface +const DATAFEED_CONFIG: Datafeed = { + datafeed_id: 'datafeed-fq_single_1_smv', + indices: ['ft_farequote'], + job_id: 'fq_single_1_smv', + query: { bool: { must: [{ match_all: {} }] } }, +}; + +export default function ({ getService }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const ml = getService('ml'); + + describe('forecasts', function () { + this.tags(['mlqa']); + + describe('with single metric job', function () { + before(async () => { + await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/farequote'); + await ml.testResources.createIndexPatternIfNeeded('ft_farequote', '@timestamp'); + await ml.testResources.setKibanaTimeZoneToUTC(); + + await ml.api.createAndRunAnomalyDetectionLookbackJob(JOB_CONFIG, DATAFEED_CONFIG); + await ml.securityUI.loginAsMlPowerUser(); + }); + + after(async () => { + await ml.api.cleanMlIndices(); + }); + + it('opens a job from job list link', async () => { + await ml.testExecution.logTestStep('navigate to job list'); + await ml.navigation.navigateToMl(); + await ml.navigation.navigateToJobManagement(); + + await ml.testExecution.logTestStep('open job in single metric viewer'); + await ml.jobTable.waitForJobsToLoad(); + await ml.jobTable.filterWithSearchString(JOB_CONFIG.job_id, 1); + + await ml.jobTable.clickOpenJobInSingleMetricViewerButton(JOB_CONFIG.job_id); + await ml.commonUI.waitForMlLoadingIndicatorToDisappear(); + }); + + it('displays job results', async () => { + await ml.testExecution.logTestStep('pre-fills the job selection'); + await ml.jobSelection.assertJobSelection([JOB_CONFIG.job_id]); + + await ml.testExecution.logTestStep('pre-fills the detector input'); + await ml.singleMetricViewer.assertDetectorInputExist(); + await ml.singleMetricViewer.assertDetectorInputValue('0'); + + await ml.testExecution.logTestStep('displays the chart'); + await ml.singleMetricViewer.assertChartExist(); + + await ml.testExecution.logTestStep('should not display the forecasts toggle checkbox'); + await ml.forecast.assertForecastCheckboxMissing(); + + await ml.testExecution.logTestStep('should open the forecasts modal'); + await ml.forecast.assertForecastButtonExists(); + await ml.forecast.assertForecastButtonEnabled(true); + await ml.forecast.openForecastModal(); + await ml.forecast.assertForecastModalRunButtonEnabled(true); + + await ml.testExecution.logTestStep('should run the forecast and close the modal'); + await ml.forecast.clickForecastModalRunButton(); + + await ml.testExecution.logTestStep('should display the forecasts toggle checkbox'); + await ml.forecast.assertForecastCheckboxExists(); + + await ml.testExecution.logTestStep( + 'should display the forecast in the single metric chart' + ); + await ml.forecast.assertForecastChartElementsExists(); + + await ml.testExecution.logTestStep('should hide the forecast in the single metric chart'); + await ml.forecast.clickForecastCheckbox(); + await ml.forecast.assertForecastChartElementsHidden(); + + await ml.testExecution.logTestStep('should open the forecasts modal and list the forecast'); + await ml.forecast.assertForecastButtonExists(); + await ml.forecast.assertForecastButtonEnabled(true); + await ml.forecast.openForecastModal(); + await ml.forecast.assertForecastTableExists(); + await ml.forecast.assertForecastTableNotEmpty(); + }); + }); + }); +} diff --git a/x-pack/test/functional/apps/ml/anomaly_detection/index.ts b/x-pack/test/functional/apps/ml/anomaly_detection/index.ts index d87da8469db1..ed5f618f8664 100644 --- a/x-pack/test/functional/apps/ml/anomaly_detection/index.ts +++ b/x-pack/test/functional/apps/ml/anomaly_detection/index.ts @@ -24,5 +24,6 @@ export default function ({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./annotations')); loadTestFile(require.resolve('./aggregated_scripted_job')); loadTestFile(require.resolve('./custom_urls')); + loadTestFile(require.resolve('./forecasts')); }); } diff --git a/x-pack/test/functional/apps/ml/permissions/full_ml_access.ts b/x-pack/test/functional/apps/ml/permissions/full_ml_access.ts index 448774f1a0c7..356e38221796 100644 --- a/x-pack/test/functional/apps/ml/permissions/full_ml_access.ts +++ b/x-pack/test/functional/apps/ml/permissions/full_ml_access.ts @@ -237,11 +237,11 @@ export default function ({ getService }: FtrProviderContext) { await ml.testExecution.logTestStep( 'should display the forecast modal with enabled run button' ); - await ml.singleMetricViewer.assertForecastButtonExists(); - await ml.singleMetricViewer.assertForecastButtonEnabled(true); - await ml.singleMetricViewer.openForecastModal(); - await ml.singleMetricViewer.assertForecastModalRunButtonEnabled(true); - await ml.singleMetricViewer.closeForecastModal(); + await ml.forecast.assertForecastButtonExists(); + await ml.forecast.assertForecastButtonEnabled(true); + await ml.forecast.openForecastModal(); + await ml.forecast.assertForecastModalRunButtonEnabled(true); + await ml.forecast.closeForecastModal(); }); it('should display elements on Anomaly Explorer page correctly', async () => { diff --git a/x-pack/test/functional/apps/ml/permissions/read_ml_access.ts b/x-pack/test/functional/apps/ml/permissions/read_ml_access.ts index b96da7485078..be57904b9445 100644 --- a/x-pack/test/functional/apps/ml/permissions/read_ml_access.ts +++ b/x-pack/test/functional/apps/ml/permissions/read_ml_access.ts @@ -230,11 +230,11 @@ export default function ({ getService }: FtrProviderContext) { await ml.testExecution.logTestStep( 'should display the forecast modal with disabled run button' ); - await ml.singleMetricViewer.assertForecastButtonExists(); - await ml.singleMetricViewer.assertForecastButtonEnabled(true); - await ml.singleMetricViewer.openForecastModal(); - await ml.singleMetricViewer.assertForecastModalRunButtonEnabled(false); - await ml.singleMetricViewer.closeForecastModal(); + await ml.forecast.assertForecastButtonExists(); + await ml.forecast.assertForecastButtonEnabled(true); + await ml.forecast.openForecastModal(); + await ml.forecast.assertForecastModalRunButtonEnabled(false); + await ml.forecast.closeForecastModal(); }); it('should display elements on Anomaly Explorer page correctly', async () => { diff --git a/x-pack/test/functional/services/ml/forecast.ts b/x-pack/test/functional/services/ml/forecast.ts new file mode 100644 index 000000000000..c26216c97adf --- /dev/null +++ b/x-pack/test/functional/services/ml/forecast.ts @@ -0,0 +1,126 @@ +/* + * 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 function MachineLearningForecastProvider({ getService }: FtrProviderContext) { + const testSubjects = getService('testSubjects'); + + return { + async assertForecastButtonExists() { + await testSubjects.existOrFail( + 'mlSingleMetricViewerSeriesControls > mlSingleMetricViewerButtonForecast' + ); + }, + + async assertForecastButtonEnabled(expectedValue: boolean) { + const isEnabled = await testSubjects.isEnabled( + 'mlSingleMetricViewerSeriesControls > mlSingleMetricViewerButtonForecast' + ); + expect(isEnabled).to.eql( + expectedValue, + `Expected "forecast" button to be '${expectedValue ? 'enabled' : 'disabled'}' (got '${ + isEnabled ? 'enabled' : 'disabled' + }')` + ); + }, + + async assertForecastChartElementsExists() { + await testSubjects.existOrFail(`mlForecastArea`, { + timeout: 30 * 1000, + }); + await testSubjects.existOrFail(`mlForecastValuesline`, { + timeout: 30 * 1000, + }); + await testSubjects.existOrFail(`mlForecastMarkers`, { + timeout: 30 * 1000, + }); + }, + + async assertForecastChartElementsHidden() { + await testSubjects.missingOrFail(`mlForecastArea`, { + allowHidden: true, + timeout: 30 * 1000, + }); + await testSubjects.missingOrFail(`mlForecastValuesline`, { + allowHidden: true, + timeout: 30 * 1000, + }); + await testSubjects.missingOrFail(`mlForecastMarkers`, { + allowHidden: true, + timeout: 30 * 1000, + }); + }, + + async assertForecastCheckboxExists() { + await testSubjects.existOrFail(`mlForecastCheckbox`, { + timeout: 30 * 1000, + }); + }, + + async assertForecastCheckboxMissing() { + await testSubjects.missingOrFail(`mlForecastCheckbox`, { + timeout: 30 * 1000, + }); + }, + + async clickForecastCheckbox() { + await testSubjects.click('mlForecastCheckbox'); + }, + + async openForecastModal() { + await testSubjects.click( + 'mlSingleMetricViewerSeriesControls > mlSingleMetricViewerButtonForecast' + ); + await testSubjects.existOrFail('mlModalForecast'); + }, + + async closeForecastModal() { + await testSubjects.click('mlModalForecast > mlModalForecastButtonClose'); + await this.assertForecastModalMissing(); + }, + + async assertForecastModalMissing() { + await testSubjects.missingOrFail(`mlModalForecast`, { + timeout: 30 * 1000, + }); + }, + + async assertForecastModalRunButtonEnabled(expectedValue: boolean) { + const isEnabled = await testSubjects.isEnabled('mlModalForecast > mlModalForecastButtonRun'); + expect(isEnabled).to.eql( + expectedValue, + `Expected forecast "run" button to be '${expectedValue ? 'enabled' : 'disabled'}' (got '${ + isEnabled ? 'enabled' : 'disabled' + }')` + ); + }, + + async assertForecastTableExists() { + await testSubjects.existOrFail('mlModalForecast > mlModalForecastTable'); + }, + + async clickForecastModalRunButton() { + await testSubjects.click('mlModalForecast > mlModalForecastButtonRun'); + await this.assertForecastModalMissing(); + }, + + async getForecastTableRows() { + return await testSubjects.findAll('mlModalForecastTable > ~mlForecastsListRow'); + }, + + async assertForecastTableNotEmpty() { + const tableRows = await this.getForecastTableRows(); + expect(tableRows.length).to.be.greaterThan( + 0, + `Forecast table should have at least one row (got '${tableRows.length}')` + ); + }, + }; +} diff --git a/x-pack/test/functional/services/ml/index.ts b/x-pack/test/functional/services/ml/index.ts index 17302b278222..4b48e4c0269e 100644 --- a/x-pack/test/functional/services/ml/index.ts +++ b/x-pack/test/functional/services/ml/index.ts @@ -24,6 +24,7 @@ import { MachineLearningDataVisualizerProvider } from './data_visualizer'; import { MachineLearningDataVisualizerFileBasedProvider } from './data_visualizer_file_based'; import { MachineLearningDataVisualizerIndexBasedProvider } from './data_visualizer_index_based'; import { MachineLearningDataVisualizerIndexPatternManagementProvider } from './data_visualizer_index_pattern_management'; +import { MachineLearningForecastProvider } from './forecast'; import { MachineLearningJobManagementProvider } from './job_management'; import { MachineLearningJobSelectionProvider } from './job_selection'; import { MachineLearningJobSourceSelectionProvider } from './job_source_selection'; @@ -92,6 +93,7 @@ export function MachineLearningProvider(context: FtrProviderContext) { const dataVisualizerIndexPatternManagement = MachineLearningDataVisualizerIndexPatternManagementProvider(context, dataVisualizerTable); + const forecast = MachineLearningForecastProvider(context); const jobAnnotations = MachineLearningJobAnnotationsProvider(context); const jobManagement = MachineLearningJobManagementProvider(context, api); const jobSelection = MachineLearningJobSelectionProvider(context); @@ -145,6 +147,7 @@ export function MachineLearningProvider(context: FtrProviderContext) { dataVisualizerIndexBased, dataVisualizerIndexPatternManagement, dataVisualizerTable, + forecast, jobAnnotations, jobManagement, jobSelection, diff --git a/x-pack/test/functional/services/ml/single_metric_viewer.ts b/x-pack/test/functional/services/ml/single_metric_viewer.ts index ac3fd67e3f94..29f1ded74deb 100644 --- a/x-pack/test/functional/services/ml/single_metric_viewer.ts +++ b/x-pack/test/functional/services/ml/single_metric_viewer.ts @@ -22,24 +22,6 @@ export function MachineLearningSingleMetricViewerProvider( await testSubjects.existOrFail('mlNoSingleMetricJobsFound'); }, - async assertForecastButtonExists() { - await testSubjects.existOrFail( - 'mlSingleMetricViewerSeriesControls > mlSingleMetricViewerButtonForecast' - ); - }, - - async assertForecastButtonEnabled(expectedValue: boolean) { - const isEnabled = await testSubjects.isEnabled( - 'mlSingleMetricViewerSeriesControls > mlSingleMetricViewerButtonForecast' - ); - expect(isEnabled).to.eql( - expectedValue, - `Expected "forecast" button to be '${expectedValue ? 'enabled' : 'disabled'}' (got '${ - isEnabled ? 'enabled' : 'disabled' - }')` - ); - }, - async assertDetectorInputExist() { await testSubjects.existOrFail( 'mlSingleMetricViewerSeriesControls > mlSingleMetricViewerDetectorSelect' @@ -97,28 +79,6 @@ export function MachineLearningSingleMetricViewerProvider( }); }, - async openForecastModal() { - await testSubjects.click( - 'mlSingleMetricViewerSeriesControls > mlSingleMetricViewerButtonForecast' - ); - await testSubjects.existOrFail('mlModalForecast'); - }, - - async closeForecastModal() { - await testSubjects.click('mlModalForecast > mlModalForecastButtonClose'); - await testSubjects.missingOrFail('mlModalForecast'); - }, - - async assertForecastModalRunButtonEnabled(expectedValue: boolean) { - const isEnabled = await testSubjects.isEnabled('mlModalForecast > mlModalForecastButtonRun'); - expect(isEnabled).to.eql( - expectedValue, - `Expected forecast "run" button to be '${expectedValue ? 'enabled' : 'disabled'}' (got '${ - isEnabled ? 'enabled' : 'disabled' - }')` - ); - }, - async openAnomalyExplorer() { await testSubjects.click('mlAnomalyResultsViewSelectorExplorer'); await testSubjects.existOrFail('mlPageAnomalyExplorer');