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

View file

@ -94,7 +94,9 @@ export const ChangePointsTable: FC<ChangePointsTableProps> = ({
const columns: Array<EuiBasicTableColumn<ChangePointAnnotation>> = [
{
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<ChangePointsTableProps> = ({
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<ChangePointsTableProps> = ({
},
},
{
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<ChangePointsTableProps> = ({
render: (type: ChangePointAnnotation['type']) => <EuiBadge color="hollow">{type}</EuiBadge>,
},
{
id: 'pValue',
'data-test-subj': 'aiopsChangePointPValue',
field: 'p_value',
name: (
<EuiToolTip
@ -153,6 +161,8 @@ export const ChangePointsTable: FC<ChangePointsTableProps> = ({
...(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<ChangePointsTableProps> = ({
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<ChangePointsTableProps> = ({
);
},
isPrimary: true,
'data-test-subj': 'aiopsChangePointFilterForValue',
'data-test-subj': 'aiopsChangePointFilterOutValue',
},
] as Array<DefaultItemAction<ChangePointAnnotation>>,
},
@ -247,11 +259,15 @@ export const ChangePointsTable: FC<ChangePointsTableProps> = ({
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 ? (
<EuiEmptyPrompt
@ -303,7 +319,7 @@ export const MiniChartPreview: FC<ChartComponentProps> = ({ fieldConfig, annotat
});
return (
<div>
<div data-test-subj={'aiopChangePointPreviewChart'}>
<EmbeddableComponent
id={`mini_changePointChart_${annotation.group ? annotation.group.value : annotation.label}`}
style={{ height: 80 }}

View file

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

View file

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

View file

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