[ML] Explain Log Rate Spikes: Extended Functional tests. (#138661)

Adds functional tests that use the histogram brushes and run an analysis.
This commit is contained in:
Walter Rafelsberger 2022-08-17 17:18:35 +02:00 committed by GitHub
parent 2fab2041c4
commit 148b48f905
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 294 additions and 18 deletions

View file

@ -234,6 +234,10 @@ export function DualBrush({
.attr('id', (b: DualBrush) => {
return 'aiops-brush-' + b.id;
})
.attr('data-test-subj', (b: DualBrush) => {
// Uppercase the first character of the `id` so we get aiopsBrushBaseline/aiopsBrushDeviation.
return 'aiopsBrush' + b.id.charAt(0).toUpperCase() + b.id.slice(1);
})
.each((brushObject: DualBrush, i, n) => {
const x = d3.scaleLinear().domain([min, max]).rangeRound([0, widthRef.current]);
brushObject.brush(d3.select(n[i]));
@ -314,6 +318,7 @@ export function DualBrush({
{width > 0 && (
<svg
className="aiops-dual-brush"
data-test-subj="aiopsDualBrush"
width={width}
height={BRUSH_HEIGHT}
style={{ marginLeft }}

View file

@ -66,6 +66,7 @@ export function ProgressControls({
<EuiFlexItem grow={false}>
{!isRunning && (
<EuiButton
data-test-subj={`aiopsRerunAnalysisButton${shouldRerunAnalysis ? ' shouldRerun' : ''}`}
size="s"
onClick={onRefresh}
color={shouldRerunAnalysis ? 'warning' : 'primary'}
@ -96,8 +97,8 @@ export function ProgressControls({
</EuiButton>
)}
{isRunning && (
<EuiButton size="s" onClick={onCancel}>
<FormattedMessage id="xpack.aiops.cancelButtonTitle" defaultMessage="Cancel" />
<EuiButton data-test-subj="aiopsCancelAnalysisButton" size="s" onClick={onCancel}>
<FormattedMessage id="xpack.aiops.cancelAnalysisButtonTitle" defaultMessage="Cancel" />
</EuiButton>
)}
</EuiFlexItem>

View file

@ -33,6 +33,15 @@ import { useAiOpsKibana } from '../../../kibana_context';
import { BrushBadge } from './brush_badge';
declare global {
interface Window {
/**
* Flag used to enable debugState on elastic charts
*/
_echDebugStateFlag?: boolean;
}
}
export interface DocumentCountChartPoint {
time: number | string;
value: number;
@ -271,7 +280,7 @@ export const DocumentCountChart: FC<DocumentCountChartProps> = ({
return (
<>
{isBrushVisible && (
<div className="aiopsHistogramBrushes">
<div className="aiopsHistogramBrushes" data-test-subj="aiopsHistogramBrushes">
<div css={{ height: BADGE_HEIGHT }}>
<BrushBadge
label={i18n.translate('xpack.aiops.documentCountChart.baselineBadgeLabel', {
@ -326,6 +335,7 @@ export const DocumentCountChart: FC<DocumentCountChartProps> = ({
}}
theme={chartTheme}
baseTheme={chartBaseTheme}
debugState={window._echDebugStateFlag ?? false}
/>
<Axis
id="bottom"

View file

@ -93,7 +93,7 @@ export const DocumentCountContent: FC<DocumentCountContentProps> = ({
<EuiButtonEmpty
onClick={clearSelection}
size="xs"
data-test-sub="aiopsClearSelectionBadge"
data-test-subj="aiopsClearSelectionBadge"
>
{clearSelectionLabel}
</EuiButtonEmpty>

View file

@ -112,7 +112,7 @@ export const ExplainLogRateSpikesAnalysis: FC<ExplainLogRateSpikesAnalysisProps>
const showSpikeAnalysisTable = data?.changePoints.length > 0;
return (
<>
<div data-test-subj="aiopsExplainLogRateSpikesAnalysis">
<ProgressControls
progress={data.loaded}
progressMessage={data.loadingState ?? ''}
@ -124,6 +124,7 @@ export const ExplainLogRateSpikesAnalysis: FC<ExplainLogRateSpikesAnalysisProps>
<EuiSpacer size="xs" />
{!isRunning && !showSpikeAnalysisTable && (
<EuiEmptyPrompt
data-test-subj="aiopsNoResultsFoundEmptyPrompt"
title={
<h2>
<FormattedMessage
@ -179,6 +180,6 @@ export const ExplainLogRateSpikesAnalysis: FC<ExplainLogRateSpikesAnalysisProps>
selectedChangePoint={selectedChangePoint}
/>
)}
</>
</div>
);
};

View file

@ -56,6 +56,7 @@ export const SpikeAnalysisTable: FC<SpikeAnalysisTableProps> = ({
const columns: Array<EuiBasicTableColumn<ChangePoint>> = [
{
'data-test-subj': 'aiopsSpikeAnalysisTableColumnFieldName',
field: 'fieldName',
name: i18n.translate(
'xpack.aiops.correlations.failedTransactions.correlationsTable.fieldNameLabel',
@ -64,6 +65,7 @@ export const SpikeAnalysisTable: FC<SpikeAnalysisTableProps> = ({
sortable: true,
},
{
'data-test-subj': 'aiopsSpikeAnalysisTableColumnFieldValue',
field: 'fieldValue',
name: i18n.translate(
'xpack.aiops.correlations.failedTransactions.correlationsTable.fieldValueLabel',
@ -73,6 +75,7 @@ export const SpikeAnalysisTable: FC<SpikeAnalysisTableProps> = ({
sortable: true,
},
{
'data-test-subj': 'aiopsSpikeAnalysisTableColumnLogRate',
width: NARROW_COLUMN_WIDTH,
field: 'pValue',
name: (
@ -105,6 +108,7 @@ export const SpikeAnalysisTable: FC<SpikeAnalysisTableProps> = ({
sortable: false,
},
{
'data-test-subj': 'aiopsSpikeAnalysisTableColumnPValue',
width: NARROW_COLUMN_WIDTH,
field: 'pValue',
name: (
@ -131,6 +135,7 @@ export const SpikeAnalysisTable: FC<SpikeAnalysisTableProps> = ({
sortable: true,
},
{
'data-test-subj': 'aiopsSpikeAnalysisTableColumnImpact',
width: NARROW_COLUMN_WIDTH,
field: 'pValue',
name: (
@ -210,6 +215,7 @@ export const SpikeAnalysisTable: FC<SpikeAnalysisTableProps> = ({
return (
<EuiBasicTable
data-test-subj="aiopsSpikeAnalysisTable"
compressed
columns={columns}
items={pageOfItems}
@ -219,6 +225,7 @@ export const SpikeAnalysisTable: FC<SpikeAnalysisTableProps> = ({
sorting={sorting as EuiTableSortingType<ChangePoint>}
rowProps={(changePoint) => {
return {
'data-test-subj': `aiopsSpikeAnalysisTableRow row-${changePoint.fieldName}-${changePoint.fieldValue}`,
onClick: () => {
if (onPinnedChangePoint) {
onPinnedChangePoint(changePoint);

View file

@ -6523,7 +6523,6 @@
"xpack.aiops.progressTitle": "Progression : {progress} % — {progressMessage}",
"xpack.aiops.searchPanel.totalDocCountLabel": "Total des documents : {strongTotalCount}",
"xpack.aiops.searchPanel.totalDocCountNumber": "{totalCount, plural, other {#}}",
"xpack.aiops.cancelButtonTitle": "Annuler",
"xpack.aiops.correlations.failedTransactions.correlationsTable.fieldNameLabel": "Nom du champ",
"xpack.aiops.correlations.failedTransactions.correlationsTable.fieldValueLabel": "Valeur du champ",
"xpack.aiops.correlations.failedTransactions.correlationsTable.impactLabel": "Impact",

View file

@ -6519,7 +6519,6 @@
"xpack.aiops.progressTitle": "進行状況:{progress}% — {progressMessage}",
"xpack.aiops.searchPanel.totalDocCountLabel": "合計ドキュメント数:{strongTotalCount}",
"xpack.aiops.searchPanel.totalDocCountNumber": "{totalCount, plural, other {#}}",
"xpack.aiops.cancelButtonTitle": "キャンセル",
"xpack.aiops.correlations.failedTransactions.correlationsTable.fieldNameLabel": "フィールド名",
"xpack.aiops.correlations.failedTransactions.correlationsTable.fieldValueLabel": "フィールド値",
"xpack.aiops.correlations.failedTransactions.correlationsTable.impactLabel": "インパクト",

View file

@ -6526,7 +6526,6 @@
"xpack.aiops.progressTitle": "进度:{progress}% — {progressMessage}",
"xpack.aiops.searchPanel.totalDocCountLabel": "文档总数:{strongTotalCount}",
"xpack.aiops.searchPanel.totalDocCountNumber": "{totalCount, plural, other {#}}",
"xpack.aiops.cancelButtonTitle": "取消",
"xpack.aiops.correlations.failedTransactions.correlationsTable.fieldNameLabel": "字段名称",
"xpack.aiops.correlations.failedTransactions.correlationsTable.fieldValueLabel": "字段值",
"xpack.aiops.correlations.failedTransactions.correlationsTable.impactLabel": "影响",

View file

@ -5,12 +5,15 @@
* 2.0.
*/
import expect from '@kbn/expect';
import type { FtrProviderContext } from '../../ftr_provider_context';
import type { TestData } from './types';
import { farequoteDataViewTestData } from './test_data';
export default function ({ getPageObject, getService }: FtrProviderContext) {
const headerPage = getPageObject('header');
const elasticChart = getService('elasticChart');
const esArchiver = getService('esArchiver');
const aiops = getService('aiops');
@ -19,6 +22,8 @@ export default function ({ getPageObject, getService }: FtrProviderContext) {
function runTests(testData: TestData) {
it(`${testData.suiteTitle} loads the source data in explain log rate spikes`, async () => {
await elasticChart.setNewChartUiDebugFlag(true);
await ml.testExecution.logTestStep(
`${testData.suiteTitle} loads the saved search selection page`
);
@ -45,17 +50,75 @@ export default function ({ getPageObject, getService }: FtrProviderContext) {
await ml.testExecution.logTestStep(
`${testData.suiteTitle} displays elements in the doc count panel correctly`
);
await aiops.explainLogRateSpikes.assertTotalDocCountHeaderExist();
await aiops.explainLogRateSpikes.assertTotalDocCountChartExist();
await aiops.explainLogRateSpikes.assertTotalDocCountHeaderExists();
await aiops.explainLogRateSpikes.assertTotalDocCountChartExists();
await ml.testExecution.logTestStep(
`${testData.suiteTitle} displays elements in the page correctly`
);
await aiops.explainLogRateSpikes.assertSearchPanelExist();
await aiops.explainLogRateSpikes.assertSearchPanelExists();
await ml.testExecution.logTestStep('displays empty prompt');
await aiops.explainLogRateSpikes.assertNoWindowParametersEmptyPromptExist();
await aiops.explainLogRateSpikes.assertNoWindowParametersEmptyPromptExists();
await ml.testExecution.logTestStep('clicks the document count chart to start analysis');
await aiops.explainLogRateSpikes.clickDocumentCountChart();
await aiops.explainLogRateSpikes.assertAnalysisSectionExists();
await ml.testExecution.logTestStep('displays the no results found prompt');
await aiops.explainLogRateSpikes.assertNoResultsFoundEmptyPromptExists();
await ml.testExecution.logTestStep('adjusts the brushes to get analysis results');
await aiops.explainLogRateSpikes.assertRerunAnalysisButtonExists(false);
// Get the current width of the deviation brush for later comparison.
const brushSelectionWidthBefore = await aiops.explainLogRateSpikes.getBrushSelectionWidth(
'aiopsBrushDeviation'
);
// Get the px values for the timestamp we want to move the brush to.
const { targetPx, intervalPx } = await aiops.explainLogRateSpikes.getPxForTimestamp(
testData.brushTargetTimestamp
);
// Adjust the right brush handle
await aiops.explainLogRateSpikes.adjustBrushHandler(
'aiopsBrushDeviation',
'handle--e',
targetPx
);
// Adjust the left brush handle
await aiops.explainLogRateSpikes.adjustBrushHandler(
'aiopsBrushDeviation',
'handle--w',
targetPx - intervalPx
);
// Get the new brush selection width for later comparison.
const brushSelectionWidthAfter = await aiops.explainLogRateSpikes.getBrushSelectionWidth(
'aiopsBrushDeviation'
);
// Assert the adjusted brush: The selection width should have changed and
// we test if the selection is smaller than two bucket intervals.
// Finally, the adjusted brush should trigger
// a warning on the "Rerun analysis" button.
expect(brushSelectionWidthBefore).not.to.be(brushSelectionWidthAfter);
expect(brushSelectionWidthAfter).not.to.be.greaterThan(intervalPx * 2);
await aiops.explainLogRateSpikes.assertRerunAnalysisButtonExists(true);
await ml.testExecution.logTestStep('rerun the analysis with adjusted settings');
await aiops.explainLogRateSpikes.clickRerunAnalysisButton(true);
await aiops.explainLogRateSpikes.assertProgressTitle('Progress: 100% — Done.');
await aiops.explainLogRateSpikesAnalysisTable.assertSpikeAnalysisTableExists();
const analysisTable = await aiops.explainLogRateSpikesAnalysisTable.parseAnalysisTable();
expect(analysisTable).to.be.eql(testData.expected.analysisTable);
});
}
@ -71,6 +134,7 @@ export default function ({ getPageObject, getService }: FtrProviderContext) {
});
after(async () => {
await elasticChart.setNewChartUiDebugFlag(false);
await ml.testResources.deleteIndexPatternByTitle('ft_farequote');
});
@ -79,6 +143,7 @@ export default function ({ getPageObject, getService }: FtrProviderContext) {
it(`${farequoteDataViewTestData.suiteTitle} loads the explain log rate spikes page`, async () => {
// Start navigation from the base of the ML app.
await ml.navigation.navigateToMl();
await elasticChart.setNewChartUiDebugFlag(true);
});
runTests(farequoteDataViewTestData);

View file

@ -11,7 +11,17 @@ export const farequoteDataViewTestData: TestData = {
suiteTitle: 'farequote index pattern',
isSavedSearch: false,
sourceIndexOrSavedSearch: 'ft_farequote',
brushTargetTimestamp: 1455033600000,
expected: {
totalDocCountFormatted: '86,274',
analysisTable: [
{
fieldName: 'airline',
fieldValue: 'AAL',
logRate: 'Chart type:bar chart',
pValue: '4.63e-14',
impact: 'High',
},
],
},
};

View file

@ -10,7 +10,15 @@ export interface TestData {
isSavedSearch?: boolean;
sourceIndexOrSavedSearch: string;
rowsPerPage?: 10 | 25 | 50;
brushTargetTimestamp: number;
expected: {
totalDocCountFormatted: string;
analysisTable: Array<{
fieldName: string;
fieldValue: string;
logRate: string;
pValue: string;
impact: string;
}>;
};
}

View file

@ -10,6 +10,8 @@ import expect from '@kbn/expect';
import type { FtrProviderContext } from '../../ftr_provider_context';
export function ExplainLogRateSpikesProvider({ getService }: FtrProviderContext) {
const browser = getService('browser');
const elasticChart = getService('elasticChart');
const testSubjects = getService('testSubjects');
const retry = getService('retry');
@ -36,29 +38,130 @@ export function ExplainLogRateSpikesProvider({ getService }: FtrProviderContext)
});
},
async assertTotalDocCountHeaderExist() {
async assertTotalDocCountHeaderExists() {
await retry.tryForTime(5000, async () => {
await testSubjects.existOrFail(`aiopsTotalDocCountHeader`);
});
},
async assertTotalDocCountChartExist() {
async assertTotalDocCountChartExists() {
await retry.tryForTime(5000, async () => {
await testSubjects.existOrFail(`aiopsDocumentCountChart`);
});
},
async assertSearchPanelExist() {
async assertSearchPanelExists() {
await testSubjects.existOrFail(`aiopsSearchPanel`);
},
async assertNoWindowParametersEmptyPromptExist() {
async assertNoWindowParametersEmptyPromptExists() {
await testSubjects.existOrFail(`aiopsNoWindowParametersEmptyPrompt`);
},
async assertNoResultsFoundEmptyPromptExists() {
await testSubjects.existOrFail(`aiopsNoResultsFoundEmptyPrompt`);
},
async clickDocumentCountChart() {
await elasticChart.waitForRenderComplete();
const el = await elasticChart.getCanvas();
await browser.getActions().move({ x: 0, y: 0, origin: el._webElement }).click().perform();
await this.assertHistogramBrushesExist();
},
async clickRerunAnalysisButton(shouldRerun: boolean) {
await testSubjects.clickWhenNotDisabled(
`aiopsRerunAnalysisButton${shouldRerun ? ' shouldRerun' : ''}`
);
await retry.tryForTime(30 * 1000, async () => {
await testSubjects.existOrFail(
`aiopsRerunAnalysisButton${!shouldRerun ? ' shouldRerun' : ''}`
);
});
},
async assertHistogramBrushesExist() {
await retry.tryForTime(5000, async () => {
await testSubjects.existOrFail(`aiopsHistogramBrushes`);
// As part of the interface for the histogram brushes, the button to clear the selection should be present
await testSubjects.existOrFail(`aiopsClearSelectionBadge`);
});
},
async assertAnalysisSectionExists() {
await retry.tryForTime(5000, async () => {
await testSubjects.existOrFail(`aiopsExplainLogRateSpikesAnalysis`);
});
},
async assertRerunAnalysisButtonExists(shouldRerun: boolean) {
await testSubjects.existOrFail(
`aiopsRerunAnalysisButton${shouldRerun ? ' shouldRerun' : ''}`
);
},
async assertProgressTitle(expectedProgressTitle: string) {
await testSubjects.existOrFail('aiopProgressTitle');
const currentProgressTitle = await testSubjects.getVisibleText('aiopProgressTitle');
expect(currentProgressTitle).to.be(expectedProgressTitle);
},
async navigateToIndexPatternSelection() {
await testSubjects.click('mlMainTab explainLogRateSpikes');
await testSubjects.existOrFail('mlPageSourceSelection');
},
async getBrushSelectionWidth(selector: string) {
const brush = await testSubjects.find(selector);
const brushSelection = (await brush.findAllByClassName('selection'))[0];
const brushSelectionRect = await brushSelection._webElement.getRect();
return brushSelectionRect.width;
},
async getPxForTimestamp(timestamp: number) {
await elasticChart.waitForRenderComplete('aiopsDocumentCountChart');
const chartDebugData = await elasticChart.getChartDebugData('aiopsDocumentCountChart');
// Select the wrapper element to access its 'width' later one for the calculations.
const dualBrushWrapper = await testSubjects.find('aiopsDualBrush');
const dualBrushWrapperRect = await dualBrushWrapper._webElement.getRect();
// Get the total count of bars and index of a bar for a given timestamp in the charts debug data.
const bars = chartDebugData?.bars?.[0].bars ?? [];
const barsCount = bars.length;
const targetDeviationBarIndex = bars.findIndex((b) => b.x === timestamp);
// The pixel location based on the given timestamp, calculated by taking the share of the index value
// over the total count of bars, normalized by the wrapping element's width.
const targetPx = Math.round(
(targetDeviationBarIndex / barsCount) * dualBrushWrapperRect.width
);
// The pixel width of the interval of an individual bar of the histogram.
// Can be used as a helper to calculate the offset from the target pixel location
// to the next histogram bar.
const intervalPx = Math.round((1 / barsCount) * dualBrushWrapperRect.width);
return { targetPx, intervalPx };
},
async adjustBrushHandler(selector: string, handlerClassName: string, targetPx: number) {
const brush = await testSubjects.find(selector);
const dualBrushWrapper = await testSubjects.find('aiopsDualBrush');
const dualBrushWrapperRect = await dualBrushWrapper._webElement.getRect();
const handle = (await brush.findAllByClassName(handlerClassName))[0];
const handleRect = await handle._webElement.getRect();
const handlePx = handleRect.x - dualBrushWrapperRect.x;
const dragAndDropOffsetPx = targetPx - handlePx;
await browser.dragAndDrop(
{ location: handle, offset: { x: 0, y: 0 } },
{ location: handle, offset: { x: dragAndDropOffsetPx, y: 0 } }
);
},
};
}

View file

@ -0,0 +1,66 @@
/*
* 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';
export function ExplainLogRateSpikesAnalysisTableProvider({ getService }: FtrProviderContext) {
const testSubjects = getService('testSubjects');
return new (class AnalysisTable {
public async assertSpikeAnalysisTableExists() {
await testSubjects.existOrFail(`aiopsSpikeAnalysisTable`);
}
public async parseAnalysisTable() {
const table = await testSubjects.find('~aiopsSpikeAnalysisTable');
const $ = await table.parseDomContent();
const rows = [];
for (const tr of $.findTestSubjects('~aiopsSpikeAnalysisTableRow').toArray()) {
const $tr = $(tr);
const rowObject: {
fieldName: string;
fieldValue: string;
logRate: string;
pValue: string;
impact: string;
} = {
fieldName: $tr
.findTestSubject('aiopsSpikeAnalysisTableColumnFieldName')
.find('.euiTableCellContent')
.text()
.trim(),
fieldValue: $tr
.findTestSubject('aiopsSpikeAnalysisTableColumnFieldValue')
.find('.euiTableCellContent')
.text()
.trim(),
logRate: $tr
.findTestSubject('aiopsSpikeAnalysisTableColumnLogRate')
.find('.euiTableCellContent')
.text()
.trim(),
pValue: $tr
.findTestSubject('aiopsSpikeAnalysisTableColumnPValue')
.find('.euiTableCellContent')
.text()
.trim(),
impact: $tr
.findTestSubject('aiopsSpikeAnalysisTableColumnImpact')
.find('.euiTableCellContent')
.text()
.trim(),
};
rows.push(rowObject);
}
return rows;
}
})();
}

View file

@ -8,11 +8,14 @@
import type { FtrProviderContext } from '../../ftr_provider_context';
import { ExplainLogRateSpikesProvider } from './explain_log_rate_spikes';
import { ExplainLogRateSpikesAnalysisTableProvider } from './explain_log_rate_spikes_analysis_table';
export function AiopsProvider(context: FtrProviderContext) {
const explainLogRateSpikes = ExplainLogRateSpikesProvider(context);
const explainLogRateSpikesAnalysisTable = ExplainLogRateSpikesAnalysisTableProvider(context);
return {
explainLogRateSpikes,
explainLogRateSpikesAnalysisTable,
};
}