Skip to content

Commit

Permalink
[ML] Add trained model list permission UI tests (elastic#174045)
Browse files Browse the repository at this point in the history
## Summary

This PR adds more functional ui tests around trained model listing and
what a full access user vs a view only user can see and do.

There is more of a focus on the Add Trained Model Flyout, as that is a
newer feature.

### Details

- Add data test subjects for:
  -  space aware warning "copy"
  - add trained model button
  - trained model flyout
  - trained model flyout close button
  - trained model flyout Elser model "copy"
  - trained model flyout `Manual Download` tab
  - trained model flyout `Click to Download` tab
  - trained model flyout `Choose Model` panels
  - trained model flyout `Download` button
  - trained model flyout eland pip install code block
  - trained model flyout eland conda install code block
  - trained model flyout eland example import code block

Note: Added a helper function to make the dynamic data test subjects for
the `Click to Download` and `Manual Download` easy to reason about. It
is used in the DOM and within the test service method.

-  Add tests to the `trained models` suite
   - Add new provider for `Add Trained Model` Flyout
       - add service methods:
- asserting that while an ml power user can access two tabs within the
flyout, a viewer user can only see 1, the manual download tab.
         - to open and close the flyout
         - assert the download button exists
         - assert e5 panels exist
         - assert eland python code blocks exist
         - assert elser model copy
         - assert elser panels exist
- Modify the api's `deleteIngestPipeline` as it was failing in CI, when
an `it` block failed
- No you can choose whether to assert and it the assertion is made, the
logging occurs as well.
 - Modify `trained models` provider, adding:
- method to close the modal that pops up when a model requested to be
deleted, cannot be
   - method to select a model by name
 - Modify `trained models table` provider, adding:
- method asserting the contents of the warning for why a given model
cannot be deleted, upon attempting to delete said model that resides in
a space or spaces, that the current user cannot see.

---------

Co-authored-by: Dima Arnautov <[email protected]>
Co-authored-by: Kibana Machine <[email protected]>
Co-authored-by: Robert Oskamp <[email protected]>
  • Loading branch information
4 people authored Mar 25, 2024
1 parent b4eed69 commit 92718c6
Show file tree
Hide file tree
Showing 9 changed files with 276 additions and 16 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,7 @@ export const DeleteSpaceAwareItemCheckModal: FC<Props> = ({
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
data-test-subj="mlDeleteSpaceAwareItemCheckModalOverlayCloseButton"
size="s"
onClick={
itemCheckRespSummary?.canTakeAnyAction &&
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export interface AddModelFlyoutProps {
onSubmit: (modelId: string) => void;
}

type FlyoutTabId = 'clickToDownload' | 'manualDownload';
export type AddModelFlyoutTabId = 'clickToDownload' | 'manualDownload';

/**
* Flyout for downloading elastic curated models and showing instructions for importing third-party models.
Expand All @@ -51,7 +51,7 @@ export const AddModelFlyout: FC<AddModelFlyoutProps> = ({ onClose, onSubmit, mod
const canCreateTrainedModels = usePermissionCheck('canCreateTrainedModels');
const isClickToDownloadTabVisible = canCreateTrainedModels && modelDownloads.length > 0;

const [selectedTabId, setSelectedTabId] = useState<FlyoutTabId>(
const [selectedTabId, setSelectedTabId] = useState<AddModelFlyoutTabId>(
isClickToDownloadTabVisible ? 'clickToDownload' : 'manualDownload'
);

Expand Down Expand Up @@ -94,7 +94,12 @@ export const AddModelFlyout: FC<AddModelFlyoutProps> = ({ onClose, onSubmit, mod
}, [selectedTabId, tabs]);

return (
<EuiFlyout ownFocus onClose={onClose} aria-labelledby={'addTrainedModelFlyout'}>
<EuiFlyout
ownFocus
onClose={onClose}
aria-labelledby={'addTrainedModelFlyout'}
data-test-subj={'mlAddTrainedModelFlyout'}
>
<EuiFlyoutHeader>
<EuiTitle size="m">
<h2 id={'addTrainedModelFlyout'}>
Expand All @@ -110,6 +115,7 @@ export const AddModelFlyout: FC<AddModelFlyoutProps> = ({ onClose, onSubmit, mod
key={tab.id}
isSelected={selectedTabId === tab.id}
onClick={setSelectedTabId.bind(null, tab.id)}
data-test-subj={`mlAddTrainedModelFlyoutTab ${tab.id}`}
>
{tab.name}
</EuiTab>
Expand Down Expand Up @@ -177,7 +183,11 @@ const ClickToDownloadTabContent: FC<ClickToDownloadTabContentProps> = ({
</EuiFlexGroup>
<EuiSpacer size="s" />
<p>
<EuiText color={'subdued'} size={'s'}>
<EuiText
color={'subdued'}
size={'s'}
data-test-subj="mlAddTrainedModelFlyoutElserModelHeaderCopy"
>
<FormattedMessage
id="xpack.ml.trainedModels.addModelFlyout.elserDescription"
defaultMessage="ELSER is Elastic's NLP model for English semantic search, utilizing sparse vectors. It prioritizes intent and contextual meaning over literal term matching, optimized specifically for English documents and queries on the Elastic platform."
Expand Down Expand Up @@ -251,21 +261,30 @@ const ClickToDownloadTabContent: FC<ClickToDownloadTabContentProps> = ({
gutterSize={'s'}
alignItems={'center'}
justifyContent={'spaceBetween'}
data-test-subj="mlAddTrainedModelFlyoutChooseModelPanels"
>
<EuiFlexItem grow={false}>
<header>
<EuiText size={'s'}>
<b>
{model.os === 'Linux' && model.arch === 'amd64' ? (
<FormattedMessage
id="xpack.ml.trainedModels.addModelFlyout.intelLinuxLabel"
defaultMessage="Intel and Linux optimized"
/>
<div
data-test-subj={`mlAddTrainedModelFlyoutModelPanel-${modelName}-${model.model_id}`}
>
<FormattedMessage
id="xpack.ml.trainedModels.addModelFlyout.intelLinuxLabel"
defaultMessage="Intel and Linux optimized"
/>
</div>
) : (
<FormattedMessage
id="xpack.ml.trainedModels.addModelFlyout.crossPlatformLabel"
defaultMessage="Cross platform"
/>
<div
data-test-subj={`mlAddTrainedModelFlyoutModelPanel-${modelName}-${model.model_id}`}
>
<FormattedMessage
id="xpack.ml.trainedModels.addModelFlyout.crossPlatformLabel"
defaultMessage="Cross platform"
/>
</div>
)}
</b>
</EuiText>
Expand Down Expand Up @@ -333,6 +352,7 @@ const ClickToDownloadTabContent: FC<ClickToDownloadTabContentProps> = ({
onClick={onModelDownload.bind(null, selectedModelId!)}
fill
disabled={!selectedModelId}
data-test-subj="mlAddTrainedModelFlyoutDownloadButton"
>
<FormattedMessage
id="xpack.ml.trainedModels.addModelFlyout.downloadButtonLabel"
Expand Down Expand Up @@ -387,7 +407,12 @@ const ManualDownloadTabContent: FC = () => {
</EuiText>
</p>
<p>
<EuiCodeBlock isCopyable language="shell" fontSize={'m'}>
<EuiCodeBlock
isCopyable
language="shell"
fontSize={'m'}
data-test-subj={'mlElandPipInstallCodeBlock'}
>
$ python -m pip install eland
</EuiCodeBlock>
</p>
Expand All @@ -412,7 +437,12 @@ const ManualDownloadTabContent: FC = () => {
</EuiText>
</p>
<p>
<EuiCodeBlock isCopyable language="shell" fontSize={'m'}>
<EuiCodeBlock
isCopyable
language="shell"
fontSize={'m'}
data-test-subj={'mlElandCondaInstallCodeBlock'}
>
$ conda install -c conda-forge eland
</EuiCodeBlock>
</p>
Expand Down Expand Up @@ -442,7 +472,12 @@ const ManualDownloadTabContent: FC = () => {
/>
</b>

<EuiCodeBlock isCopyable language="shell" fontSize={'m'}>
<EuiCodeBlock
isCopyable
language="shell"
fontSize={'m'}
data-test-subj={'mlElandExampleImportCodeBlock'}
>
eland_import_hub_model <br />
--cloud-id &lt;cloud-id&gt; \ <br />
-u &lt;username&gt; -p &lt;password&gt; \ <br />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -650,7 +650,11 @@ export const ModelsList: FC<Props> = ({
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem>
<EuiButton color="danger" onClick={setModelsToDelete.bind(null, selectedModels)}>
<EuiButton
color="danger"
onClick={setModelsToDelete.bind(null, selectedModels)}
data-test-subj="mlTrainedModelsDeleteSelectedModelsButton"
>
<FormattedMessage
id="xpack.ml.trainedModels.modelsList.deleteModelsButtonLabel"
defaultMessage="Delete"
Expand Down Expand Up @@ -745,6 +749,7 @@ export const ModelsList: FC<Props> = ({
iconType={'plusInCircle'}
color={'primary'}
onClick={setIsAddModelFlyoutVisible.bind(null, true)}
data-test-subj="mlModelsAddTrainedModelButton"
>
<FormattedMessage
id="xpack.ml.trainedModels.modelsList.addModelButtonLabel"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,20 @@ export default function ({ getService }: FtrProviderContext) {
{ pipelineName: `pipeline_${modelWithPipelineData.modelId}`, expectDefinition: false },
]);
});

it('the add trained model flyout should display elements on Manual Download tab correctly', async () => {
await ml.testExecution.logTestStep('Open the Add Trained Model Flyout');
await ml.trainedModelsFlyout.open();

await ml.testExecution.logTestStep('Assert the Manual Download tab exists');
await ml.trainedModelsFlyout.assertFlyoutTabs(['manualDownload']);

await ml.testExecution.logTestStep('Assert all eland code blocks exist within the flyout');
await ml.trainedModelsFlyout.assertElandPythonClientCodeBlocks();

await ml.testExecution.logTestStep('Close the Add Trained Model flyout');
await ml.trainedModelsFlyout.close();
});
});

describe('for ML power user', () => {
Expand All @@ -157,6 +171,20 @@ export default function ({ getService }: FtrProviderContext) {
await ml.securityUI.logout();
});

it('should not be able to delete a model assigned to all spaces, and show a warning copy explaining the situation', async () => {
await ml.testExecution.logTestStep('should select the model named elser_model_2');
await ml.trainedModels.selectModel('.elser_model_2');

await ml.testExecution.logTestStep('should attempt to delete the model');
await ml.trainedModels.clickBulkDelete();

await ml.testExecution.logTestStep('assert the action is banned');
await ml.trainedModelsTable.assertSpaceAwareWarningMessage();

await ml.testExecution.logTestStep('close the eui modal');
await ml.trainedModels.closeCheckingSpacePermissionsModal();
});

it('renders trained models list', async () => {
await ml.testExecution.logTestStep(
'should display the stats bar with the total number of models'
Expand Down Expand Up @@ -487,5 +515,44 @@ export default function ({ getService }: FtrProviderContext) {
}
});
});

describe('add trained model flyout for ML power user', () => {
before(async () => {
await ml.securityUI.loginAsMlPowerUser();
await ml.navigation.navigateToTrainedModels();
await ml.commonUI.waitForRefreshButtonEnabled();

await ml.testExecution.logTestStep('Open the Add Trained Model Flyout');
await ml.trainedModelsFlyout.open();
});

after(async () => {
await ml.testExecution.logTestStep('Close the Add Trained Model flyout');
await ml.trainedModelsFlyout.close();

await ml.securityUI.logout();
});

it('should contain a Click to Download and a Manual Download tab', async () => {
await ml.testExecution.logTestStep(
'Assert the "Click to Download" and "Manual Download" tabs exists'
);
await ml.trainedModelsFlyout.assertFlyoutTabs(['clickToDownload', 'manualDownload']);
});

it('should list Elser and E5 panels contents correctly', async () => {
await ml.testExecution.logTestStep('should display the Elser header copy');
await ml.trainedModelsFlyout.assertElserModelHeaderCopy();

await ml.testExecution.logTestStep('should display the Elser Panels');
await ml.trainedModelsFlyout.assertElserPanelsExist();

await ml.testExecution.logTestStep('should display the E5 Panels');
await ml.trainedModelsFlyout.assertE5PanelsExist();

await ml.testExecution.logTestStep('should display a Download Button');
await ml.trainedModelsFlyout.assertDownloadButtonExists();
});
});
});
}
108 changes: 108 additions & 0 deletions x-pack/test/functional/services/ml/add_trained_models_flyout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/*
* 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 type { AddModelFlyoutTabId } from '@kbn/ml-plugin/public/application/model_management/add_model_flyout';
import type { FtrProviderContext } from '../../ftr_provider_context';

export function TrainedModelsFlyoutProvider({ getService }: FtrProviderContext) {
const testSubjects = getService('testSubjects');
const retry = getService('retry');

return {
async assertElserModelHeaderCopy(): Promise<void> {
await testSubjects.existOrFail('mlAddTrainedModelFlyoutElserModelHeaderCopy', {
timeout: 3_000,
});
},

async assertElserPanelsExist(): Promise<void> {
await testSubjects.existOrFail('mlAddTrainedModelFlyoutModelPanel-elser-.elser_model_2', {
timeout: 3_000,
});
await testSubjects.existOrFail(
'mlAddTrainedModelFlyoutModelPanel-elser-.elser_model_2_linux-x86_64',
{
timeout: 3_000,
}
);
},

async assertE5PanelsExist(): Promise<void> {
await testSubjects.existOrFail(
'mlAddTrainedModelFlyoutModelPanel-e5-.multilingual-e5-small',
{
timeout: 3_000,
}
);
await testSubjects.existOrFail(
'mlAddTrainedModelFlyoutModelPanel-e5-.multilingual-e5-small_linux-x86_64',
{
timeout: 3_000,
}
);
},

async assertDownloadButtonExists(): Promise<void> {
await testSubjects.existOrFail('mlAddTrainedModelFlyoutDownloadButton', {
timeout: 3_000,
});
},

async assertOpen(expectOpen: boolean): Promise<void> {
if (expectOpen) {
await testSubjects.existOrFail('mlAddTrainedModelFlyout', {
timeout: 3_000,
});
} else {
await testSubjects.missingOrFail('mlAddTrainedModelFlyout', {
timeout: 3_000,
});
}
},

async open() {
await retry.tryForTime(3_000, async () => {
await testSubjects.click('mlModelsAddTrainedModelButton');
await this.assertOpen(true);
});
},

async close(): Promise<void> {
await retry.tryForTime(3_000, async () => {
await testSubjects.click('euiFlyoutCloseButton');
await this.assertOpen(false);
});
},

async assertFlyoutTabs(tabs: AddModelFlyoutTabId[]): Promise<void> {
const expectedTabCount = tabs.length;
const actualTabs = await testSubjects.findAll('~mlAddTrainedModelFlyoutTab', 3_000);
const actualTabCount = actualTabs.length;

expect(actualTabCount).to.be(expectedTabCount);

for await (const tab of tabs)
await testSubjects.existOrFail(`mlAddTrainedModelFlyoutTab ${tab}`, {
timeout: 3_000,
});
},

async assertElandPythonClientCodeBlocks() {
expect(await testSubjects.getVisibleText('mlElandPipInstallCodeBlock')).to.match(
/python -m pip install eland/
);
expect(await testSubjects.getVisibleText('mlElandCondaInstallCodeBlock')).to.match(
/conda install -c conda-forge eland/
);
expect(await testSubjects.getVisibleText('mlElandExampleImportCodeBlock')).to.match(
/eland_import_hub_model/
);
},
};
}
14 changes: 14 additions & 0 deletions x-pack/test/functional/services/ml/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1515,8 +1515,22 @@ export function MachineLearningAPIProvider({ getService }: FtrProviderContext) {
return ingestPipeline;
},

async ingestPipelineExists(modelId: string, usePrefix: boolean = true): Promise<boolean> {
const { status } = await esSupertest.get(
`/_ingest/pipeline/${usePrefix ? 'pipeline_' : ''}${modelId}`
);
if (status !== 200) return false;
return true;
},

async deleteIngestPipeline(modelId: string, usePrefix: boolean = true) {
log.debug(`Deleting ingest pipeline for trained model with id "${modelId}"`);

if (!(await this.ingestPipelineExists(modelId, usePrefix))) {
log.debug('> Ingest pipeline does not exist, nothing to delete');
return;
}

const { body, status } = await esSupertest.delete(
`/_ingest/pipeline/${usePrefix ? 'pipeline_' : ''}${modelId}`
);
Expand Down
Loading

0 comments on commit 92718c6

Please sign in to comment.