[ML] Add functional tests for Change Point Detection UI (#158164)

## Summary

Part of https://github.com/elastic/kibana/issues/157980

Adds functional tests for the Change Point Detection UI.

### Checklist

- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
This commit is contained in:
Dima Arnautov 2023-05-23 13:26:09 +02:00 committed by GitHub
parent 84d070d2f7
commit 3db3709eb4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 311 additions and 8 deletions

View file

@ -121,6 +121,7 @@ export const ChangePointDetectionPage: FC = () => {
onClick={() => setFlyoutVisible(!isFlyoutVisible)} onClick={() => setFlyoutVisible(!isFlyoutVisible)}
size={'s'} size={'s'}
disabled={!hasSelectedChangePoints} disabled={!hasSelectedChangePoints}
data-test-subj={'aiopsChangePointDetectionViewSelected'}
> >
<FormattedMessage <FormattedMessage
id="xpack.aiops.changePointDetection.viewSelectedButtonLabel" id="xpack.aiops.changePointDetection.viewSelectedButtonLabel"
@ -149,6 +150,7 @@ export const ChangePointDetectionPage: FC = () => {
onClose={setFlyoutVisible.bind(null, false)} onClose={setFlyoutVisible.bind(null, false)}
aria-labelledby={'change_point_charts'} aria-labelledby={'change_point_charts'}
size={'l'} size={'l'}
data-test-subj={'aiopsChangePointDetectionSelectedCharts'}
> >
<EuiFlyoutHeader hasBorder> <EuiFlyoutHeader hasBorder>
<EuiTitle size="m"> <EuiTitle size="m">

View file

@ -94,7 +94,9 @@ export const ChangePointsTable: FC<ChangePointsTableProps> = ({
const columns: Array<EuiBasicTableColumn<ChangePointAnnotation>> = [ const columns: Array<EuiBasicTableColumn<ChangePointAnnotation>> = [
{ {
id: 'timestamp',
field: 'timestamp', field: 'timestamp',
'data-test-subj': 'aiopsChangePointTimestamp',
name: i18n.translate('xpack.aiops.changePointDetection.timeColumn', { name: i18n.translate('xpack.aiops.changePointDetection.timeColumn', {
defaultMessage: 'Time', defaultMessage: 'Time',
}), }),
@ -104,6 +106,8 @@ export const ChangePointsTable: FC<ChangePointsTableProps> = ({
render: (timestamp: ChangePointAnnotation['timestamp']) => dateFormatter.convert(timestamp), render: (timestamp: ChangePointAnnotation['timestamp']) => dateFormatter.convert(timestamp),
}, },
{ {
id: 'preview',
'data-test-subj': 'aiopsChangePointPreview',
name: i18n.translate('xpack.aiops.changePointDetection.previewColumn', { name: i18n.translate('xpack.aiops.changePointDetection.previewColumn', {
defaultMessage: 'Preview', defaultMessage: 'Preview',
}), }),
@ -118,6 +122,8 @@ export const ChangePointsTable: FC<ChangePointsTableProps> = ({
}, },
}, },
{ {
id: 'type',
'data-test-subj': 'aiopsChangePointType',
field: 'type', field: 'type',
name: i18n.translate('xpack.aiops.changePointDetection.typeColumn', { name: i18n.translate('xpack.aiops.changePointDetection.typeColumn', {
defaultMessage: 'Type', defaultMessage: 'Type',
@ -127,6 +133,8 @@ export const ChangePointsTable: FC<ChangePointsTableProps> = ({
render: (type: ChangePointAnnotation['type']) => <EuiBadge color="hollow">{type}</EuiBadge>, render: (type: ChangePointAnnotation['type']) => <EuiBadge color="hollow">{type}</EuiBadge>,
}, },
{ {
id: 'pValue',
'data-test-subj': 'aiopsChangePointPValue',
field: 'p_value', field: 'p_value',
name: ( name: (
<EuiToolTip <EuiToolTip
@ -153,6 +161,8 @@ export const ChangePointsTable: FC<ChangePointsTableProps> = ({
...(fieldConfig.splitField ...(fieldConfig.splitField
? [ ? [
{ {
id: 'groupName',
'data-test-subj': 'aiopsChangePointGroupName',
field: 'group.name', field: 'group.name',
name: i18n.translate('xpack.aiops.changePointDetection.fieldNameColumn', { name: i18n.translate('xpack.aiops.changePointDetection.fieldNameColumn', {
defaultMessage: 'Field name', defaultMessage: 'Field name',
@ -160,6 +170,8 @@ export const ChangePointsTable: FC<ChangePointsTableProps> = ({
truncateText: false, truncateText: false,
}, },
{ {
id: 'groupValue',
'data-test-subj': 'aiopsChangePointGroupValue',
field: 'group.value', field: 'group.value',
name: i18n.translate('xpack.aiops.changePointDetection.fieldValueColumn', { name: i18n.translate('xpack.aiops.changePointDetection.fieldValueColumn', {
defaultMessage: 'Field value', defaultMessage: 'Field value',
@ -218,7 +230,7 @@ export const ChangePointsTable: FC<ChangePointsTableProps> = ({
); );
}, },
isPrimary: true, isPrimary: true,
'data-test-subj': 'aiopsChangePointFilterForValue', 'data-test-subj': 'aiopsChangePointFilterOutValue',
}, },
] as Array<DefaultItemAction<ChangePointAnnotation>>, ] as Array<DefaultItemAction<ChangePointAnnotation>>,
}, },
@ -247,11 +259,15 @@ export const ChangePointsTable: FC<ChangePointsTableProps> = ({
itemId={'id'} itemId={'id'}
selection={selectionValue} selection={selectionValue}
loading={isLoading} loading={isLoading}
data-test-subj={`aiopsChangePointResultsTable ${isLoading ? 'loading' : 'loaded'}`}
items={annotations} items={annotations}
columns={columns} columns={columns}
pagination={{ pageSizeOptions: [5, 10, 15] }} pagination={{ pageSizeOptions: [5, 10, 15] }}
sorting={defaultSorting} sorting={defaultSorting}
hasActions={hasActions} hasActions={hasActions}
rowProps={(item) => ({
'data-test-subj': `aiopsChangePointResultsTableRow row-${item.id}`,
})}
message={ message={
isLoading ? ( isLoading ? (
<EuiEmptyPrompt <EuiEmptyPrompt
@ -303,7 +319,7 @@ export const MiniChartPreview: FC<ChartComponentProps> = ({ fieldConfig, annotat
}); });
return ( return (
<div> <div data-test-subj={'aiopChangePointPreviewChart'}>
<EmbeddableComponent <EmbeddableComponent
id={`mini_changePointChart_${annotation.group ? annotation.group.value : annotation.label}`} id={`mini_changePointChart_${annotation.group ? annotation.group.value : annotation.label}`}
style={{ height: 80 }} style={{ height: 80 }}

View file

@ -93,6 +93,7 @@ export const FieldsConfig: FC = () => {
return ( return (
<React.Fragment key={key}> <React.Fragment key={key}>
<FieldPanel <FieldPanel
data-test-subj={`aiopsChangePointPanel_${index}`}
fieldConfig={fieldConfig} fieldConfig={fieldConfig}
onChange={(value) => onChange(value, index)} onChange={(value) => onChange(value, index)}
onRemove={onRemove.bind(null, index)} onRemove={onRemove.bind(null, index)}
@ -105,7 +106,11 @@ export const FieldsConfig: FC = () => {
</React.Fragment> </React.Fragment>
); );
})} })}
<EuiButton onClick={onAdd} disabled={fieldConfigs.length >= MAX_CHANGE_POINT_CONFIGS}> <EuiButton
onClick={onAdd}
disabled={fieldConfigs.length >= MAX_CHANGE_POINT_CONFIGS}
data-test-subj={'aiopsChangePointAddConfig'}
>
<FormattedMessage <FormattedMessage
id="xpack.aiops.changePointDetection.addButtonLabel" id="xpack.aiops.changePointDetection.addButtonLabel"
defaultMessage="Add" defaultMessage="Add"
@ -121,6 +126,7 @@ export interface FieldPanelProps {
onChange: (update: FieldConfig) => void; onChange: (update: FieldConfig) => void;
onRemove: () => void; onRemove: () => void;
onSelectionChange: (update: SelectedChangePoint[]) => void; onSelectionChange: (update: SelectedChangePoint[]) => void;
'data-test-subj': string;
} }
/** /**
@ -137,6 +143,7 @@ const FieldPanel: FC<FieldPanelProps> = ({
onRemove, onRemove,
removeDisabled, removeDisabled,
onSelectionChange, onSelectionChange,
'data-test-subj': dataTestSubj,
}) => { }) => {
const { combinedQuery, requestParams } = useChangePointDetectionContext(); const { combinedQuery, requestParams } = useChangePointDetectionContext();
@ -151,7 +158,7 @@ const FieldPanel: FC<FieldPanelProps> = ({
} = useChangePointResults(fieldConfig, requestParams, combinedQuery, splitFieldCardinality); } = useChangePointResults(fieldConfig, requestParams, combinedQuery, splitFieldCardinality);
return ( return (
<EuiPanel paddingSize="s" hasBorder hasShadow={false}> <EuiPanel paddingSize="s" hasBorder hasShadow={false} data-test-subj={dataTestSubj}>
<EuiFlexGroup alignItems={'center'} justifyContent={'spaceBetween'} gutterSize={'s'}> <EuiFlexGroup alignItems={'center'} justifyContent={'spaceBetween'} gutterSize={'s'}>
<EuiFlexItem grow={false}> <EuiFlexItem grow={false}>
<EuiFlexGroup alignItems={'center'} gutterSize={'s'}> <EuiFlexGroup alignItems={'center'} gutterSize={'s'}>

View file

@ -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);
});
});
}

View file

@ -30,5 +30,6 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) {
}); });
loadTestFile(require.resolve('./explain_log_rate_spikes')); loadTestFile(require.resolve('./explain_log_rate_spikes'));
loadTestFile(require.resolve('./change_point_detection'));
}); });
} }

View file

@ -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}`
);
},
};
}

View file

@ -12,6 +12,8 @@ import { ExplainLogRateSpikesAnalysisTableProvider } from './explain_log_rate_sp
import { ExplainLogRateSpikesAnalysisGroupsTableProvider } from './explain_log_rate_spikes_analysis_groups_table'; import { ExplainLogRateSpikesAnalysisGroupsTableProvider } from './explain_log_rate_spikes_analysis_groups_table';
import { ExplainLogRateSpikesDataGeneratorProvider } from './explain_log_rate_spikes_data_generator'; import { ExplainLogRateSpikesDataGeneratorProvider } from './explain_log_rate_spikes_data_generator';
import { LogPatternAnalysisPageProvider } from './log_pattern_analysis_page'; 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) { export function AiopsProvider(context: FtrProviderContext) {
const explainLogRateSpikesPage = ExplainLogRateSpikesPageProvider(context); const explainLogRateSpikesPage = ExplainLogRateSpikesPageProvider(context);
@ -21,7 +23,12 @@ export function AiopsProvider(context: FtrProviderContext) {
const explainLogRateSpikesDataGenerator = ExplainLogRateSpikesDataGeneratorProvider(context); const explainLogRateSpikesDataGenerator = ExplainLogRateSpikesDataGeneratorProvider(context);
const logPatternAnalysisPageProvider = LogPatternAnalysisPageProvider(context); const logPatternAnalysisPageProvider = LogPatternAnalysisPageProvider(context);
const tableService = MlTableServiceProvider(context);
const changePointDetectionPage = ChangePointDetectionPageProvider(context, tableService);
return { return {
changePointDetectionPage,
explainLogRateSpikesPage, explainLogRateSpikesPage,
explainLogRateSpikesAnalysisTable, explainLogRateSpikesAnalysisTable,
explainLogRateSpikesAnalysisGroupsTable, explainLogRateSpikesAnalysisGroupsTable,

View file

@ -21,7 +21,8 @@ export function MlTableServiceProvider({ getPageObject, getService }: FtrProvide
public readonly tableTestSubj: string, public readonly tableTestSubj: string,
public readonly tableRowSubj: string, public readonly tableRowSubj: string,
public readonly columns: Array<{ id: string; testSubj: string }>, public readonly columns: Array<{ id: string; testSubj: string }>,
public readonly searchInputSubj: string public readonly searchInputSubj: string,
public readonly parentSubj?: string
) {} ) {}
public async assertTableLoaded() { public async assertTableLoaded() {
@ -33,7 +34,9 @@ export function MlTableServiceProvider({ getPageObject, getService }: FtrProvide
} }
public async parseTable() { 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 $ = await table.parseDomContent();
const rows = []; const rows = [];
@ -138,6 +141,28 @@ export function MlTableServiceProvider({ getPageObject, getService }: FtrProvide
await this.assertTableSorting(columnName, columnIndex, direction); 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 { return {
@ -146,10 +171,11 @@ export function MlTableServiceProvider({ getPageObject, getService }: FtrProvide
tableTestSubj: string, tableTestSubj: string,
tableRowSubj: string, tableRowSubj: string,
columns: Array<{ id: string; testSubj: string }>, columns: Array<{ id: string; testSubj: string }>,
searchInputSubj: string searchInputSubj: string,
parentSubj?: string
) { ) {
Object.defineProperty(TableService, 'name', { value: name }); Object.defineProperty(TableService, 'name', { value: name });
return new TableService(tableTestSubj, tableRowSubj, columns, searchInputSubj); return new TableService(tableTestSubj, tableRowSubj, columns, searchInputSubj, parentSubj);
}, },
}; };
} }

View file

@ -46,5 +46,9 @@ export function MachineLearningJobSourceSelectionProvider({ getService }: FtrPro
async selectSourceForExplainLogRateSpikes(sourceName: string) { async selectSourceForExplainLogRateSpikes(sourceName: string) {
await this.selectSource(sourceName, 'aiopsExplainLogRateSpikesPage'); await this.selectSource(sourceName, 'aiopsExplainLogRateSpikesPage');
}, },
async selectSourceForChangePointDetection(sourceName: string) {
await this.selectSource(sourceName, 'aiopsChangePointDetectionPage');
},
}; };
} }