[ML] Functional tests for the Test Model action (#146399)

## Summary

Part of #142456

Adds functional tests for the Test model action


### 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 2022-11-30 16:48:27 +01:00 committed by GitHub
parent 8a6cc0cd26
commit 9ad78b244a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 266 additions and 47 deletions

View file

@ -24,6 +24,7 @@ interface MlJobEditorProps {
syntaxChecking?: boolean;
theme?: string;
onChange?: EuiCodeEditorProps['onChange'];
'data-test-subj'?: string;
}
export const MLJobEditor: FC<MlJobEditorProps> = ({
value,
@ -34,6 +35,7 @@ export const MLJobEditor: FC<MlJobEditorProps> = ({
syntaxChecking = true,
theme = 'textmate',
onChange = () => {},
'data-test-subj': dataTestSubj,
}) => {
if (mode === ML_EDITOR_MODE.XJSON) {
try {
@ -61,6 +63,7 @@ export const MLJobEditor: FC<MlJobEditorProps> = ({
useSoftTabs: true,
}}
onChange={onChange}
data-test-subj={dataTestSubj}
/>
);
};

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React, { FC, useState, useMemo, useCallback } from 'react';
import React, { FC, useState, useMemo, useCallback, FormEventHandler } from 'react';
import useObservable from 'react-use/lib/useObservable';
import { FormattedMessage } from '@kbn/i18n-react';
@ -18,6 +18,7 @@ import {
EuiHorizontalRule,
EuiLoadingSpinner,
EuiText,
EuiForm,
} from '@elastic/eui';
import { ErrorMessage } from '../../inference_error';
@ -41,17 +42,21 @@ export const IndexInputForm: FC<Props> = ({ inferrer }) => {
const outputComponent = useMemo(() => inferrer.getOutputComponent(), [inferrer]);
const infoComponent = useMemo(() => inferrer.getInfoComponent(), [inferrer]);
const run = useCallback(async () => {
setErrorText(null);
try {
await inferrer.infer();
} catch (e) {
setErrorText(extractErrorMessage(e));
}
}, [inferrer]);
const run: FormEventHandler<HTMLFormElement> = useCallback(
async (event) => {
event.preventDefault();
setErrorText(null);
try {
await inferrer.infer();
} catch (e) {
setErrorText(extractErrorMessage(e));
}
},
[inferrer]
);
return (
<>
<EuiForm component={'form'} onSubmit={run}>
<>{infoComponent}</>
<InferenceInputFormIndexControls inferrer={inferrer} data={data} />
@ -60,9 +65,10 @@ export const IndexInputForm: FC<Props> = ({ inferrer }) => {
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<EuiButton
onClick={run}
disabled={runningState === RUNNING_STATE.RUNNING || isValid === false}
fullWidth={false}
data-test-subj={'mlTestModelTestButton'}
type={'submit'}
>
<FormattedMessage
id="xpack.ml.trainedModels.testModelsFlyout.inferenceInputForm.runButton"
@ -105,6 +111,6 @@ export const IndexInputForm: FC<Props> = ({ inferrer }) => {
: null}
{runningState === RUNNING_STATE.FINISHED ? <>{outputComponent}</> : null}
</>
</EuiForm>
);
};

View file

@ -5,11 +5,11 @@
* 2.0.
*/
import React, { FC, useState, useMemo, useCallback } from 'react';
import React, { FC, useState, useMemo, useCallback, FormEventHandler } from 'react';
import useObservable from 'react-use/lib/useObservable';
import { FormattedMessage } from '@kbn/i18n-react';
import { EuiSpacer, EuiButton, EuiTabs, EuiTab } from '@elastic/eui';
import { EuiSpacer, EuiButton, EuiTabs, EuiTab, EuiForm } from '@elastic/eui';
import { ErrorMessage } from '../../inference_error';
import { extractErrorMessage } from '../../../../../../../common';
@ -37,25 +37,30 @@ export const TextInputForm: FC<Props> = ({ inferrer }) => {
const outputComponent = useMemo(() => inferrer.getOutputComponent(), [inferrer]);
const infoComponent = useMemo(() => inferrer.getInfoComponent(), [inferrer]);
const run = useCallback(async () => {
setErrorText(null);
try {
await inferrer.infer();
} catch (e) {
setErrorText(extractErrorMessage(e));
}
}, [inferrer]);
const run: FormEventHandler<HTMLFormElement> = useCallback(
async (event) => {
event.preventDefault();
setErrorText(null);
try {
await inferrer.infer();
} catch (e) {
setErrorText(extractErrorMessage(e));
}
},
[inferrer]
);
return (
<>
<EuiForm component={'form'} onSubmit={run}>
<>{infoComponent}</>
<>{inputComponent}</>
<EuiSpacer size="m" />
<div>
<EuiButton
onClick={run}
disabled={runningState === RUNNING_STATE.RUNNING || isValid === false}
fullWidth={false}
data-test-subj={'mlTestModelTestButton'}
type="submit"
>
<FormattedMessage
id="xpack.ml.trainedModels.testModelsFlyout.inferenceInputForm.runButton"
@ -100,13 +105,15 @@ export const TextInputForm: FC<Props> = ({ inferrer }) => {
</>
) : null}
{runningState === RUNNING_STATE.FINISHED ? <>{outputComponent}</> : null}
{runningState === RUNNING_STATE.FINISHED ? (
<div data-test-subj={'mlTestModelOutput'}>{outputComponent}</div>
) : null}
</>
) : (
<RawOutput inferrer={inferrer} />
)}
</>
) : null}
</>
</EuiForm>
);
};

View file

@ -59,7 +59,7 @@ export const RawOutput: FC<{
return (
<>
<MLJobEditor value={rawResponse ?? ''} readOnly={true} />
<MLJobEditor data-test-subj={'mlTestModelRawOutput'} value={rawResponse ?? ''} readOnly />
</>
);
};

View file

@ -57,10 +57,12 @@ const LanguageIdent: FC<{
return (
<>
<EuiText size="s">{inputText}</EuiText>
<EuiText size="s" data-test-subj={'mlTestModelLangIdentInputText'}>
{inputText}
</EuiText>
<EuiSpacer size="s" />
<EuiTitle size="xxs">
<h4>{title}</h4>
<h4 data-test-subj={'mlTestModelLangIdentTitle'}>{title}</h4>
</EuiTitle>
<EuiSpacer />

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React, { FC } from 'react';
import React, { type FC, Fragment } from 'react';
import useObservable from 'react-use/lib/useObservable';
import {
EuiFlexGroup,
@ -72,17 +72,17 @@ export const PredictionProbabilityList: FC<{
) : null}
{response.map(({ value, predictionProbability }) => (
<>
<Fragment key={value}>
<EuiProgress value={predictionProbability * 100} max={100} size="m" />
<EuiSpacer size="s" />
<EuiFlexGroup>
<>
<EuiFlexItem>{value}</EuiFlexItem>
<EuiFlexItem grow={false}>{predictionProbability}</EuiFlexItem>
</>
<EuiFlexItem data-test-subj={`mlTestModelLangIdentInputValue`}>{value}</EuiFlexItem>
<EuiFlexItem data-test-subj={`mlTestModelLangIdentInputProbability`} grow={false}>
{predictionProbability}
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer />
</>
</Fragment>
))}
</>
);

View file

@ -44,6 +44,7 @@ export const TextInput: FC<{
onChange={(e) => {
setInputText(e.target.value);
}}
data-test-subj={`mlTestModelInputText`}
/>
</EuiFormRow>
);

View file

@ -13,5 +13,7 @@ export const OutputLoadingContent: FC<{ text: string }> = ({ text }) => {
const actualLines = text.split(/\r\n|\r|\n/).length + 1;
const lines = actualLines > 4 && actualLines <= 10 ? actualLines : 4;
return <EuiLoadingContent lines={lines as LineRange} />;
return (
<EuiLoadingContent data-test-subj={'mlTestModelLoadingContent'} lines={lines as LineRange} />
);
};

View file

@ -93,7 +93,7 @@ export default function ({ getService }: FtrProviderContext) {
await ml.trainedModelsTable.assertPipelinesTabContent(false);
});
it('displays the built-in model and no actions are enabled', async () => {
it('displays the built-in model with only Test action enabled', async () => {
await ml.testExecution.logTestStep('should display the model in the table');
await ml.trainedModelsTable.filterWithSearchString(builtInModelData.modelId, 1);
@ -121,6 +121,21 @@ export default function ({ getService }: FtrProviderContext) {
builtInModelData.modelId,
false
);
await ml.testExecution.logTestStep('should have enabled the button that opens Test flyout');
await ml.trainedModelsTable.assertModelTestButtonExists(builtInModelData.modelId, true);
await ml.trainedModelsTable.testModel(
'lang_ident',
builtInModelData.modelId,
{
inputText: 'Goedemorgen! Ik ben een appel.',
},
{
title: 'This looks like Dutch,Flemish',
topLang: { code: 'nl', minProbability: 0.9 },
}
);
});
it('displays a model with an ingest pipeline and delete action is disabled', async () => {

View file

@ -402,17 +402,28 @@ export function MachineLearningCommonUIProvider({
});
},
async invokeTableRowAction(rowSelector: string, actionTestSubject: string) {
async invokeTableRowAction(
rowSelector: string,
actionTestSubject: string,
fromContextMenu: boolean = true
) {
await retry.tryForTime(30 * 1000, async () => {
await this.ensureAllMenuPopoversClosed();
await testSubjects.click(`${rowSelector} > euiCollapsedItemActionsButton`);
await find.existsByCssSelector('euiContextMenuPanel');
if (fromContextMenu) {
await this.ensureAllMenuPopoversClosed();
const isEnabled = await testSubjects.isEnabled(actionTestSubject);
await testSubjects.click(`${rowSelector} > euiCollapsedItemActionsButton`);
await find.existsByCssSelector('euiContextMenuPanel');
expect(isEnabled).to.eql(true, `Expected action "${actionTestSubject}" to be enabled.`);
const isEnabled = await testSubjects.isEnabled(actionTestSubject);
await testSubjects.click(actionTestSubject);
expect(isEnabled).to.eql(true, `Expected action "${actionTestSubject}" to be enabled.`);
await testSubjects.click(actionTestSubject);
} else {
const isEnabled = await testSubjects.isEnabled(`${rowSelector} > ${actionTestSubject}`);
expect(isEnabled).to.eql(true, `Expected action "${actionTestSubject}" to be enabled.`);
await testSubjects.click(`${rowSelector} > ${actionTestSubject}`);
}
});
},
};

View file

@ -131,7 +131,7 @@ export function MachineLearningProvider(context: FtrProviderContext) {
const alerting = MachineLearningAlertingProvider(context, api, commonUI);
const swimLane = SwimLaneProvider(context);
const trainedModels = TrainedModelsProvider(context, commonUI);
const trainedModelsTable = TrainedModelsTableProvider(context, commonUI);
const trainedModelsTable = TrainedModelsTableProvider(context, commonUI, trainedModels);
const mlNodesPanel = MlNodesPanelProvider(context);
const notifications = NotificationsProvider(context, commonUI, tableService);

View file

@ -5,13 +5,76 @@
* 2.0.
*/
// eslint-disable-next-line max-classes-per-file
import expect from '@kbn/expect';
import { ProvidedType } from '@kbn/test';
import { FtrProviderContext } from '../../ftr_provider_context';
import { MlCommonUI } from './common_ui';
export type TrainedModelsActions = ProvidedType<typeof TrainedModelsProvider>;
export type ModelType = 'lang_ident';
export interface MappedInputParams {
lang_ident: LangIdentInput;
}
export interface MappedOutput {
lang_ident: LangIdentOutput;
}
export function TrainedModelsProvider({ getService }: FtrProviderContext, mlCommonUI: MlCommonUI) {
const testSubjects = getService('testSubjects');
const retry = getService('retry');
const browser = getService('browser');
class TestModelFactory {
public static createAssertionInstance(modelType: ModelType) {
switch (modelType) {
case 'lang_ident':
return new TestLangIdentModel();
default:
throw new Error(`Testing class for ${modelType} is not implemented`);
}
}
}
class TestModelBase implements TestTrainedModel<BaseInput, unknown> {
async setRequiredInput(input: BaseInput): Promise<void> {
await testSubjects.setValue('mlTestModelInputText', input.inputText);
await this.assertTestInputText(input.inputText);
}
async assertTestInputText(expectedText: string) {
const actualValue = await testSubjects.getAttribute('mlTestModelInputText', 'value');
expect(actualValue).to.eql(
expectedText,
`Expected input text to equal ${expectedText}, got ${actualValue}`
);
}
assertModelOutput(expectedOutput: unknown): Promise<void> {
throw new Error('assertModelOutput has to be implemented per model type');
}
}
class TestLangIdentModel
extends TestModelBase
implements TestTrainedModel<LangIdentInput, LangIdentOutput>
{
async assertModelOutput(expectedOutput: LangIdentOutput) {
const title = await testSubjects.getVisibleText('mlTestModelLangIdentTitle');
expect(title).to.eql(expectedOutput.title);
const values = await testSubjects.findAll('mlTestModelLangIdentInputValue');
const topValue = await values[0].getVisibleText();
expect(topValue).to.eql(expectedOutput.topLang.code);
const probabilities = await testSubjects.findAll('mlTestModelLangIdentInputProbability');
const topProbability = Number(await probabilities[0].getVisibleText());
expect(topProbability).to.above(expectedOutput.topLang.minProbability);
}
}
return {
async assertStats(expectedTotalCount: number) {
@ -28,5 +91,80 @@ export function TrainedModelsProvider({ getService }: FtrProviderContext, mlComm
async assertRowsNumberPerPage(rowsNumber: 10 | 25 | 100) {
await mlCommonUI.assertRowsNumberPerPage('mlModelsTableContainer', rowsNumber);
},
async assertTestButtonEnabled(expectedValue: boolean = false) {
const isEnabled = await testSubjects.isEnabled('mlTestModelTestButton');
expect(isEnabled).to.eql(
expectedValue,
`Expected trained model "Test" button to be '${
expectedValue ? 'enabled' : 'disabled'
}' (got '${isEnabled ? 'enabled' : 'disabled'}')`
);
},
async testModel() {
await testSubjects.click('mlTestModelTestButton');
},
async assertTestInputText(expectedText: string) {
const actualValue = await testSubjects.getAttribute('mlTestModelInputText', 'value');
expect(actualValue).to.eql(
expectedText,
`Expected input text to equal ${expectedText}, got ${actualValue}`
);
},
async waitForResultsToLoad() {
await testSubjects.waitForEnabled('mlTestModelTestButton');
await retry.tryForTime(5000, async () => {
await testSubjects.existOrFail(`mlTestModelOutput`);
});
},
async testModelOutput(
modelType: ModelType,
inputParams: MappedInputParams[typeof modelType],
expectedOutput: MappedOutput[typeof modelType]
) {
await this.assertTestButtonEnabled(false);
const modelTest = TestModelFactory.createAssertionInstance(modelType);
await modelTest.setRequiredInput(inputParams);
await this.assertTestButtonEnabled(true);
await this.testModel();
await this.waitForResultsToLoad();
await modelTest.assertModelOutput(expectedOutput);
await this.ensureTestFlyoutClosed();
},
async ensureTestFlyoutClosed() {
await retry.tryForTime(5000, async () => {
await browser.pressKeys(browser.keys.ESCAPE);
await testSubjects.missingOrFail('mlTestModelsFlyout');
});
},
};
}
export interface BaseInput {
inputText: string;
}
export type LangIdentInput = BaseInput;
export interface LangIdentOutput {
title: string;
topLang: { code: string; minProbability: number };
}
/**
* Interface that needed to be implemented by all model types
*/
interface TestTrainedModel<Input extends BaseInput, Output> {
setRequiredInput(input: Input): Promise<void>;
assertTestInputText(inputText: Input['inputText']): Promise<void>;
assertModelOutput(expectedOutput: Output): Promise<void>;
}

View file

@ -12,6 +12,7 @@ import { upperFirst } from 'lodash';
import { WebElementWrapper } from '../../../../../test/functional/services/lib/web_element_wrapper';
import type { FtrProviderContext } from '../../ftr_provider_context';
import type { MlCommonUI } from './common_ui';
import { MappedInputParams, MappedOutput, ModelType, TrainedModelsActions } from './trained_models';
export interface TrainedModelRowData {
id: string;
@ -23,7 +24,8 @@ export type MlTrainedModelsTable = ProvidedType<typeof TrainedModelsTableProvide
export function TrainedModelsTableProvider(
{ getService }: FtrProviderContext,
mlCommonUI: MlCommonUI
mlCommonUI: MlCommonUI,
trainedModelsActions: TrainedModelsActions
) {
const testSubjects = getService('testSubjects');
const retry = getService('retry');
@ -180,6 +182,34 @@ export function TrainedModelsTableProvider(
);
}
public async assertModelTestButtonExists(modelId: string, expectedValue: boolean) {
const actionExists = await testSubjects.exists(
this.rowSelector(modelId, 'mlModelsTableRowTestAction')
);
expect(actionExists).to.eql(
expectedValue,
`Expected test action button for trained model '${modelId}' to be ${
expectedValue ? 'visible' : 'hidden'
} (got ${actionExists ? 'visible' : 'hidden'})`
);
}
public async testModel(
modelType: ModelType,
modelId: string,
inputParams: MappedInputParams[typeof modelType],
expectedResult: MappedOutput[typeof modelType]
) {
await mlCommonUI.invokeTableRowAction(
this.rowSelector(modelId),
'mlModelsTableRowTestAction',
false
);
await this.assertTestFlyoutExists();
await trainedModelsActions.testModelOutput(modelType, inputParams, expectedResult);
}
public async deleteModel(modelId: string) {
await mlCommonUI.invokeTableRowAction(
this.rowSelector(modelId),
@ -208,6 +238,10 @@ export function TrainedModelsTableProvider(
await testSubjects.existOrFail('mlModelsDeleteModal', { timeout: 60 * 1000 });
}
public async assertTestFlyoutExists() {
await testSubjects.existOrFail('mlTestModelsFlyout', { timeout: 60 * 1000 });
}
public async assertStartDeploymentModalExists(expectExist = true) {
if (expectExist) {
await testSubjects.existOrFail('mlModelsStartDeploymentModal', { timeout: 60 * 1000 });