[ML] Job import and export functional tests (#110578)

* [ML] Job import export functional tests

* adding title check

* adding dfa tests

* removing export file

* adds bad data test

* commented code

* adding export job tests

* adds version to file names

* improving tests

* removing comment

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
James Gowdy 2021-09-03 14:05:53 +01:00 committed by GitHub
parent d83c8244a2
commit 71571c5b60
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 1043 additions and 72 deletions

View file

@ -63,6 +63,7 @@ export const ExportJobsFlyout: FC<Props> = ({ isDisabled, currentTab }) => {
const [exporting, setExporting] = useState(false);
const [selectedJobType, setSelectedJobType] = useState<JobType>(currentTab);
const [switchTabConfirmVisible, setSwitchTabConfirmVisible] = useState(false);
const [switchTabNextTab, setSwitchTabNextTab] = useState<JobType>(currentTab);
const { displayErrorToast, displaySuccessToast } = useMemo(
() => toastNotificationServiceProvider(toasts),
[toasts]
@ -170,16 +171,23 @@ export const ExportJobsFlyout: FC<Props> = ({ isDisabled, currentTab }) => {
}
}
const attemptTabSwitch = useCallback(() => {
// if the user has already selected some jobs, open a confirm modal
// rather than changing tabs
if (selectedJobIds.length > 0) {
setSwitchTabConfirmVisible(true);
return;
}
const attemptTabSwitch = useCallback(
(jobType: JobType) => {
if (jobType === selectedJobType) {
return;
}
// if the user has already selected some jobs, open a confirm modal
// rather than changing tabs
if (selectedJobIds.length > 0) {
setSwitchTabNextTab(jobType);
setSwitchTabConfirmVisible(true);
return;
}
switchTab();
}, [selectedJobIds]);
switchTab(jobType);
},
[selectedJobIds]
);
useEffect(() => {
setSelectedJobDependencies(
@ -187,10 +195,7 @@ export const ExportJobsFlyout: FC<Props> = ({ isDisabled, currentTab }) => {
);
}, [selectedJobIds]);
function switchTab() {
const jobType =
selectedJobType === 'anomaly-detector' ? 'data-frame-analytics' : 'anomaly-detector';
function switchTab(jobType: JobType) {
setSwitchTabConfirmVisible(false);
setSelectedJobIds([]);
setSelectedJobType(jobType);
@ -211,7 +216,12 @@ export const ExportJobsFlyout: FC<Props> = ({ isDisabled, currentTab }) => {
{showFlyout === true && isDisabled === false && (
<>
<EuiFlyout onClose={() => setShowFlyout(false)} hideCloseButton size="s">
<EuiFlyout
onClose={() => setShowFlyout(false)}
hideCloseButton
size="s"
data-test-subj="mlJobMgmtExportJobsFlyout"
>
<EuiFlyoutHeader hasBorder>
<EuiTitle size="m">
<h2>
@ -227,8 +237,9 @@ export const ExportJobsFlyout: FC<Props> = ({ isDisabled, currentTab }) => {
<EuiTabs size="s">
<EuiTab
isSelected={selectedJobType === 'anomaly-detector'}
onClick={attemptTabSwitch}
onClick={() => attemptTabSwitch('anomaly-detector')}
disabled={exporting}
data-test-subj="mlJobMgmtExportJobsADTab"
>
<FormattedMessage
id="xpack.ml.importExport.exportFlyout.adTab"
@ -237,8 +248,9 @@ export const ExportJobsFlyout: FC<Props> = ({ isDisabled, currentTab }) => {
</EuiTab>
<EuiTab
isSelected={selectedJobType === 'data-frame-analytics'}
onClick={attemptTabSwitch}
onClick={() => attemptTabSwitch('data-frame-analytics')}
disabled={exporting}
data-test-subj="mlJobMgmtExportJobsDFATab"
>
<FormattedMessage
id="xpack.ml.importExport.exportFlyout.dfaTab"
@ -254,26 +266,40 @@ export const ExportJobsFlyout: FC<Props> = ({ isDisabled, currentTab }) => {
<LoadingSpinner />
) : (
<>
<EuiButtonEmpty size="xs" onClick={onSelectAll} isDisabled={isDisabled}>
<FormattedMessage
id="xpack.ml.importExport.exportFlyout.adSelectAllButton"
defaultMessage="Select all"
/>
<EuiButtonEmpty
size="xs"
onClick={onSelectAll}
isDisabled={isDisabled}
data-test-subj="mlJobMgmtExportJobsSelectAllButton"
>
{selectedJobIds.length === adJobIds.length ? (
<FormattedMessage
id="xpack.ml.importExport.exportFlyout.adDeselectAllButton"
defaultMessage="Deselect all"
/>
) : (
<FormattedMessage
id="xpack.ml.importExport.exportFlyout.adSelectAllButton"
defaultMessage="Select all"
/>
)}
</EuiButtonEmpty>
<EuiSpacer size="xs" />
{adJobIds.map((id) => (
<div key={id}>
<EuiCheckbox
id={id}
label={id}
checked={selectedJobIds.includes(id)}
onChange={(e) => toggleSelectedJob(e.target.checked, id)}
/>
<EuiSpacer size="s" />
</div>
))}
<div data-test-subj="mlJobMgmtExportJobsADJobList">
{adJobIds.map((id) => (
<div key={id}>
<EuiCheckbox
id={id}
label={id}
checked={selectedJobIds.includes(id)}
onChange={(e) => toggleSelectedJob(e.target.checked, id)}
/>
<EuiSpacer size="s" />
</div>
))}
</div>
</>
)}
</>
@ -284,26 +310,39 @@ export const ExportJobsFlyout: FC<Props> = ({ isDisabled, currentTab }) => {
<LoadingSpinner />
) : (
<>
<EuiButtonEmpty size="xs" onClick={onSelectAll} isDisabled={isDisabled}>
<FormattedMessage
id="xpack.ml.importExport.exportFlyout.dfaSelectAllButton"
defaultMessage="Select all"
/>
<EuiButtonEmpty
size="xs"
onClick={onSelectAll}
isDisabled={isDisabled}
data-test-subj="mlJobMgmtExportJobsSelectAllButton"
>
{selectedJobIds.length === dfaJobIds.length ? (
<FormattedMessage
id="xpack.ml.importExport.exportFlyout.dfaDeselectAllButton"
defaultMessage="Deselect all"
/>
) : (
<FormattedMessage
id="xpack.ml.importExport.exportFlyout.dfaSelectAllButton"
defaultMessage="Select all"
/>
)}
</EuiButtonEmpty>
<EuiSpacer size="xs" />
{dfaJobIds.map((id) => (
<div key={id}>
<EuiCheckbox
id={id}
label={id}
checked={selectedJobIds.includes(id)}
onChange={(e) => toggleSelectedJob(e.target.checked, id)}
/>
<EuiSpacer size="s" />
</div>
))}
<div data-test-subj="mlJobMgmtExportJobsDFAJobList">
{dfaJobIds.map((id) => (
<div key={id}>
<EuiCheckbox
id={id}
label={id}
checked={selectedJobIds.includes(id)}
onChange={(e) => toggleSelectedJob(e.target.checked, id)}
/>
<EuiSpacer size="s" />
</div>
))}
</div>
</>
)}
</>
@ -329,6 +368,7 @@ export const ExportJobsFlyout: FC<Props> = ({ isDisabled, currentTab }) => {
disabled={selectedJobIds.length === 0 || exporting === true}
onClick={onExport}
fill
data-test-subj="mlJobMgmtExportExportButton"
>
<FormattedMessage
id="xpack.ml.importExport.exportFlyout.exportButton"
@ -343,7 +383,7 @@ export const ExportJobsFlyout: FC<Props> = ({ isDisabled, currentTab }) => {
{switchTabConfirmVisible === true ? (
<SwitchTabsConfirm
onCancel={setSwitchTabConfirmVisible.bind(null, false)}
onConfirm={switchTab}
onConfirm={() => switchTab(switchTabNextTab)}
/>
) : null}
</>

View file

@ -30,6 +30,7 @@ export const CannotImportJobsCallout: FC<Props> = ({ jobs, autoExpand = false })
values: { num: jobs.length },
})}
color="warning"
data-test-subj="mlJobMgmtImportJobsCannotBeImportedCallout"
>
{autoExpand ? (
<SkippedJobList jobs={jobs} />

View file

@ -21,10 +21,12 @@ export const CannotReadFileCallout: FC = () => {
})}
color="warning"
>
<FormattedMessage
id="xpack.ml.importExport.importFlyout.cannotReadFileCallout.body"
defaultMessage="Please select a file contained Machine Learning jobs which have been exported from Kibana using the Export Jobs option"
/>
<div data-test-subj="mlJobMgmtImportJobsFileReadErrorCallout">
<FormattedMessage
id="xpack.ml.importExport.importFlyout.cannotReadFileCallout.body"
defaultMessage="Please select a file contained Machine Learning jobs which have been exported from Kibana using the Export Jobs option"
/>
</div>
</EuiCallOut>
</>
);

View file

@ -341,7 +341,12 @@ export const ImportJobsFlyout: FC<Props> = ({ isDisabled }) => {
<FlyoutButton onClick={toggleFlyout} isDisabled={isDisabled} />
{showFlyout === true && isDisabled === false && (
<EuiFlyout onClose={setShowFlyout.bind(null, false)} hideCloseButton size="m">
<EuiFlyout
onClose={setShowFlyout.bind(null, false)}
hideCloseButton
size="m"
data-test-subj="mlJobMgmtImportJobsFlyout"
>
<EuiFlyoutHeader hasBorder>
<EuiTitle size="m">
<h2>
@ -373,22 +378,26 @@ export const ImportJobsFlyout: FC<Props> = ({ isDisabled }) => {
{showFileReadError ? <CannotReadFileCallout /> : null}
{totalJobsRead > 0 && jobType !== null && (
<>
<div data-test-subj="mlJobMgmtImportJobsFileRead">
<EuiSpacer size="l" />
{jobType === 'anomaly-detector' && (
<FormattedMessage
id="xpack.ml.importExport.importFlyout.selectedFiles.ad"
defaultMessage="{num} anomaly detection {num, plural, one {job} other {jobs}} read from file"
values={{ num: totalJobsRead }}
/>
<div data-test-subj="mlJobMgmtImportJobsADTitle">
<FormattedMessage
id="xpack.ml.importExport.importFlyout.selectedFiles.ad"
defaultMessage="{num} anomaly detection {num, plural, one {job} other {jobs}} read from file"
values={{ num: totalJobsRead }}
/>
</div>
)}
{jobType === 'data-frame-analytics' && (
<FormattedMessage
id="xpack.ml.importExport.importFlyout.selectedFiles.dfa"
defaultMessage="{num} data frame analytics {num, plural, one {job} other {jobs}} read from file"
values={{ num: totalJobsRead }}
/>
<div data-test-subj="mlJobMgmtImportJobsDFATitle">
<FormattedMessage
id="xpack.ml.importExport.importFlyout.selectedFiles.dfa"
defaultMessage="{num} data frame analytics {num, plural, one {job} other {jobs}} read from file"
values={{ num: totalJobsRead }}
/>
</div>
)}
<EuiSpacer size="m" />
@ -426,6 +435,7 @@ export const ImportJobsFlyout: FC<Props> = ({ isDisabled }) => {
value={jobId.jobId}
onChange={(e) => renameJob(e.target.value, i)}
isInvalid={jobId.jobIdValid === false}
data-test-subj="mlJobMgmtImportJobIdInput"
/>
</EuiFormRow>
@ -465,7 +475,7 @@ export const ImportJobsFlyout: FC<Props> = ({ isDisabled }) => {
<EuiSpacer size="m" />
</div>
))}
</>
</div>
)}
</>
</EuiFlyoutBody>
@ -484,7 +494,12 @@ export const ImportJobsFlyout: FC<Props> = ({ isDisabled }) => {
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton disabled={importDisabled} onClick={onImport} fill>
<EuiButton
disabled={importDisabled}
onClick={onImport}
fill
data-test-subj="mlJobMgmtImportImportButton"
>
<FormattedMessage
id="xpack.ml.importExport.importFlyout.closeButton.importButton"
defaultMessage="Import"

View file

@ -0,0 +1,314 @@
/*
* 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';
import { Job, Datafeed } from '../../../../../plugins/ml/common/types/anomaly_detection_jobs';
import type { DataFrameAnalyticsConfig } from '../../../../../plugins/ml/public/application/data_frame_analytics/common';
const testADJobs: Array<{ job: Job; datafeed: Datafeed }> = [
{
// @ts-expect-error not full interface
job: {
job_id: 'fq_single_1_smv',
groups: ['farequote', 'automated', 'single-metric'],
description: 'mean(responsetime) on farequote dataset with 15m bucket span',
analysis_config: {
bucket_span: '15m',
detectors: [
{
detector_description: 'mean(responsetime)',
function: 'mean',
field_name: 'responsetime',
},
],
influencers: [],
},
analysis_limits: {
model_memory_limit: '10mb',
categorization_examples_limit: 4,
},
data_description: {
time_field: '@timestamp',
time_format: 'epoch_ms',
},
model_plot_config: {
enabled: true,
annotations_enabled: true,
},
model_snapshot_retention_days: 10,
daily_model_snapshot_retention_after_days: 1,
results_index_name: 'shared',
allow_lazy_open: false,
},
datafeed: {
datafeed_id: 'datafeed-fq_single_1_smv',
job_id: 'fq_single_1_smv',
query: {
bool: {
must: [
{
match_all: {},
},
],
},
},
indices: ['ft_farequote'],
scroll_size: 1000,
delayed_data_check_config: {
enabled: true,
},
},
},
{
// @ts-expect-error not full interface
job: {
job_id: 'fq_single_2_smv',
groups: ['farequote', 'automated', 'single-metric'],
description: 'low_mean(responsetime) on farequote dataset with 15m bucket span',
analysis_config: {
bucket_span: '15m',
detectors: [
{
detector_description: 'low_mean(responsetime)',
function: 'low_mean',
field_name: 'responsetime',
},
],
influencers: ['responsetime'],
},
analysis_limits: {
model_memory_limit: '11mb',
categorization_examples_limit: 4,
},
data_description: {
time_field: '@timestamp',
time_format: 'epoch_ms',
},
model_plot_config: {
enabled: true,
annotations_enabled: true,
},
model_snapshot_retention_days: 10,
daily_model_snapshot_retention_after_days: 1,
results_index_name: 'shared',
allow_lazy_open: false,
},
datafeed: {
datafeed_id: 'datafeed-fq_single_2_smv',
job_id: 'fq_single_2_smv',
query: {
bool: {
must: [
{
match_all: {},
},
],
},
},
indices: ['ft_farequote'],
scroll_size: 1000,
delayed_data_check_config: {
enabled: true,
},
},
},
{
// @ts-expect-error not full interface
job: {
job_id: 'fq_single_3_smv',
groups: ['farequote', 'automated', 'single-metric'],
description: 'high_mean(responsetime) on farequote dataset with 15m bucket span',
analysis_config: {
bucket_span: '15m',
detectors: [
{
detector_description: 'high_mean(responsetime)',
function: 'high_mean',
field_name: 'responsetime',
},
],
influencers: ['responsetime'],
},
analysis_limits: {
model_memory_limit: '11mb',
categorization_examples_limit: 4,
},
data_description: {
time_field: '@timestamp',
time_format: 'epoch_ms',
},
model_plot_config: {
enabled: true,
annotations_enabled: true,
},
model_snapshot_retention_days: 10,
daily_model_snapshot_retention_after_days: 1,
results_index_name: 'shared',
allow_lazy_open: false,
},
datafeed: {
datafeed_id: 'datafeed-fq_single_3_smv',
job_id: 'fq_single_3_smv',
query: {
bool: {
must: [
{
match_all: {},
},
],
},
},
indices: ['ft_farequote'],
scroll_size: 1000,
delayed_data_check_config: {
enabled: true,
},
},
},
];
const testDFAJobs: DataFrameAnalyticsConfig[] = [
// @ts-expect-error not full interface
{
id: `bm_1_1`,
description:
"Classification job based on 'ft_bank_marketing' dataset with dependentVariable 'y' and trainingPercent '20'",
source: {
index: ['ft_bank_marketing'],
query: {
match_all: {},
},
},
dest: {
index: 'user-bm_1_1',
results_field: 'ml',
},
analysis: {
classification: {
prediction_field_name: 'test',
dependent_variable: 'y',
training_percent: 20,
},
},
analyzed_fields: {
includes: [],
excludes: [],
},
model_memory_limit: '60mb',
allow_lazy_start: false,
},
// @ts-expect-error not full interface
{
id: `ihp_1_2`,
description: 'This is the job description',
source: {
index: ['ft_ihp_outlier'],
query: {
match_all: {},
},
},
dest: {
index: 'user-ihp_1_2',
results_field: 'ml',
},
analysis: {
outlier_detection: {},
},
analyzed_fields: {
includes: [],
excludes: [],
},
model_memory_limit: '5mb',
},
// @ts-expect-error not full interface
{
id: `egs_1_3`,
description: 'This is the job description',
source: {
index: ['ft_egs_regression'],
query: {
match_all: {},
},
},
dest: {
index: 'user-egs_1_3',
results_field: 'ml',
},
analysis: {
regression: {
prediction_field_name: 'test',
dependent_variable: 'stab',
training_percent: 20,
},
},
analyzed_fields: {
includes: [],
excludes: [],
},
model_memory_limit: '20mb',
},
];
export default function ({ getService }: FtrProviderContext) {
const esArchiver = getService('esArchiver');
const ml = getService('ml');
describe('export jobs', function () {
this.tags(['mlqa']);
before(async () => {
await ml.api.cleanMlIndices();
await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/farequote');
await ml.testResources.createIndexPatternIfNeeded('ft_farequote', '@timestamp');
await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/bm_classification');
await ml.testResources.createIndexPatternIfNeeded('ft_bank_marketing', '@timestamp');
await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/ihp_outlier');
await ml.testResources.createIndexPatternIfNeeded('ft_ihp_outlier', '@timestamp');
await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/egs_regression');
await ml.testResources.createIndexPatternIfNeeded('ft_egs_regression', '@timestamp');
await ml.testResources.setKibanaTimeZoneToUTC();
for (const { job, datafeed } of testADJobs) {
await ml.api.createAnomalyDetectionJob(job);
await ml.api.createDatafeed(datafeed);
}
for (const job of testDFAJobs) {
await ml.api.createDataFrameAnalyticsJob(job);
}
await ml.securityUI.loginAsMlPowerUser();
await ml.navigation.navigateToStackManagement();
await ml.navigation.navigateToStackManagementJobsListPage();
});
after(async () => {
await ml.api.cleanMlIndices();
ml.stackManagementJobs.deleteExportedFiles([
'anomaly_detection_jobs',
'data_frame_analytics_jobs',
]);
});
it('opens export flyout and exports anomaly detector jobs', async () => {
await ml.stackManagementJobs.openExportFlyout();
await ml.stackManagementJobs.selectExportJobType('anomaly-detector');
await ml.stackManagementJobs.selectExportJobSelectAll('anomaly-detector');
await ml.stackManagementJobs.selectExportJobs();
await ml.stackManagementJobs.assertExportedADJobsAreCorrect(testADJobs);
});
it('opens export flyout and exports data frame analytics jobs', async () => {
await ml.stackManagementJobs.openExportFlyout();
await ml.stackManagementJobs.selectExportJobType('data-frame-analytics');
await ml.stackManagementJobs.selectExportJobSelectAll('data-frame-analytics');
await ml.stackManagementJobs.selectExportJobs();
await ml.stackManagementJobs.assertExportedDFAJobsAreCorrect(testDFAJobs);
});
});
}

View file

@ -0,0 +1,213 @@
[
{
"job": {
"job_id": "ad-test1",
"description": "",
"analysis_config": {
"bucket_span": "15m",
"summary_count_field_name": "doc_count",
"detectors": [
{
"detector_description": "mean(responsetime)",
"function": "mean",
"field_name": "responsetime",
"detector_index": 0
}
],
"influencers": []
},
"analysis_limits": {
"model_memory_limit": "11mb",
"categorization_examples_limit": 4
},
"data_description": {
"time_field": "@timestamp",
"time_format": "epoch_ms"
},
"model_plot_config": {
"enabled": true,
"annotations_enabled": true
},
"model_snapshot_retention_days": 10,
"daily_model_snapshot_retention_after_days": 1,
"results_index_name": "shared",
"allow_lazy_open": false
},
"datafeed": {
"datafeed_id": "datafeed-ad-test1",
"job_id": "ad-test1",
"query": {
"bool": {
"must": [
{
"match_all": {}
}
]
}
},
"indices": [
"ft_farequote"
],
"aggregations": {
"buckets": {
"date_histogram": {
"field": "@timestamp",
"fixed_interval": "90000ms"
},
"aggregations": {
"responsetime": {
"avg": {
"field": "responsetime"
}
},
"@timestamp": {
"max": {
"field": "@timestamp"
}
}
}
}
},
"scroll_size": 1000,
"delayed_data_check_config": {
"enabled": true
}
}
},
{
"job": {
"job_id": "ad-test2",
"groups": [
"newgroup"
],
"description": "",
"analysis_config": {
"bucket_span": "15m",
"summary_count_field_name": "doc_count",
"detectors": [
{
"detector_description": "mean(responsetime)",
"function": "mean",
"field_name": "responsetime",
"detector_index": 0
}
],
"influencers": []
},
"analysis_limits": {
"model_memory_limit": "11mb",
"categorization_examples_limit": 4
},
"data_description": {
"time_field": "@timestamp",
"time_format": "epoch_ms"
},
"model_plot_config": {
"enabled": true,
"annotations_enabled": true
},
"model_snapshot_retention_days": 10,
"daily_model_snapshot_retention_after_days": 1,
"results_index_name": "shared",
"allow_lazy_open": false
},
"datafeed": {
"datafeed_id": "datafeed-ad-test2",
"job_id": "ad-test2",
"query": {
"bool": {
"must": [
{
"match_all": {}
}
]
}
},
"indices": [
"missing"
],
"aggregations": {
"buckets": {
"date_histogram": {
"field": "@timestamp",
"fixed_interval": "90000ms"
},
"aggregations": {
"responsetime": {
"avg": {
"field": "responsetime"
}
},
"@timestamp": {
"max": {
"field": "@timestamp"
}
}
}
}
},
"scroll_size": 1000,
"delayed_data_check_config": {
"enabled": true
}
}
},
{
"job": {
"job_id": "ad-test3",
"custom_settings": {},
"description": "",
"analysis_config": {
"bucket_span": "15m",
"detectors": [
{
"detector_description": "mean(responsetime) partitionfield=airline",
"function": "mean",
"field_name": "responsetime",
"partition_field_name": "airline",
"detector_index": 0
}
],
"influencers": [
"airline"
]
},
"analysis_limits": {
"model_memory_limit": "11mb",
"categorization_examples_limit": 4
},
"data_description": {
"time_field": "@timestamp",
"time_format": "epoch_ms"
},
"model_plot_config": {
"enabled": false,
"annotations_enabled": false
},
"model_snapshot_retention_days": 10,
"daily_model_snapshot_retention_after_days": 1,
"results_index_name": "shared",
"allow_lazy_open": false
},
"datafeed": {
"datafeed_id": "datafeed-ad-test3",
"job_id": "ad-test3",
"query": {
"bool": {
"must": [
{
"match_all": {}
}
]
}
},
"indices": [
"ft_farequote"
],
"scroll_size": 1000,
"delayed_data_check_config": {
"enabled": true
}
}
}
]

View file

@ -0,0 +1 @@
Hey! this isn't JSON.

View file

@ -0,0 +1,60 @@
[
{
"id": "dfa-test1",
"description": "Classification job based on 'ft_bank_marketing' dataset with dependentVariable 'y' and trainingPercent '20'",
"source": {
"index": [
"ft_bank_marketing"
],
"query": {
"match_all": {}
}
},
"dest": {
"index": "user-dfa-test1",
"results_field": "ml"
},
"analysis": {
"classification": {
"prediction_field_name": "user-test",
"dependent_variable": "y",
"training_percent": 20
}
},
"analyzed_fields": {
"includes": [],
"excludes": []
},
"model_memory_limit": "60mb",
"allow_lazy_start": false
},
{
"id": "dfa-test2",
"description": "Classification job based on 'ft_bank_marketing' dataset with dependentVariable 'y' and trainingPercent '20'",
"source": {
"index": [
"missing-index"
],
"query": {
"match_all": {}
}
},
"dest": {
"index": "user-dfa-test2",
"results_field": "ml"
},
"analysis": {
"classification": {
"prediction_field_name": "test",
"dependent_variable": "y",
"training_percent": 20
}
},
"analyzed_fields": {
"includes": [],
"excludes": []
},
"model_memory_limit": "60mb",
"allow_lazy_start": false
}
]

View file

@ -0,0 +1,107 @@
/*
* 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 path from 'path';
import { FtrProviderContext } from '../../../ftr_provider_context';
import { JobType } from '../../../../../plugins/ml/common/types/saved_objects';
export default function ({ getService }: FtrProviderContext) {
const esArchiver = getService('esArchiver');
const ml = getService('ml');
const testDataListPositive = [
{
filePath: path.join(__dirname, 'files_to_import', 'anomaly_detection_jobs_7.16.json'),
expected: {
jobType: 'anomaly-detector' as JobType,
jobIds: ['ad-test1', 'ad-test3'],
skippedJobIds: ['ad-test2'],
},
},
{
filePath: path.join(__dirname, 'files_to_import', 'data_frame_analytics_jobs_7.16.json'),
expected: {
jobType: 'data-frame-analytics' as JobType,
jobIds: ['dfa-test1'],
skippedJobIds: ['dfa-test2'],
},
},
];
describe('import jobs', function () {
this.tags(['mlqa']);
before(async () => {
await ml.api.cleanMlIndices();
await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/farequote');
await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/bm_classification');
await ml.testResources.createIndexPatternIfNeeded('ft_farequote', '@timestamp');
await ml.testResources.createIndexPatternIfNeeded('ft_bank_marketing', '@timestamp');
await ml.testResources.setKibanaTimeZoneToUTC();
await ml.securityUI.loginAsMlPowerUser();
await ml.navigation.navigateToStackManagement();
await ml.navigation.navigateToStackManagementJobsListPage();
});
after(async () => {
await ml.api.cleanMlIndices();
});
for (const testData of testDataListPositive) {
it('selects and reads file', async () => {
await ml.testExecution.logTestStep('selects job import');
await ml.stackManagementJobs.openImportFlyout();
await ml.stackManagementJobs.selectFileToImport(testData.filePath);
});
it('has the correct importable jobs', async () => {
await ml.stackManagementJobs.assertCorrectTitle(
[...testData.expected.jobIds, ...testData.expected.skippedJobIds].length,
testData.expected.jobType
);
await ml.stackManagementJobs.assertJobIdsExist(testData.expected.jobIds);
await ml.stackManagementJobs.assertJobIdsSkipped(testData.expected.skippedJobIds);
});
it('imports jobs', async () => {
await ml.stackManagementJobs.importJobs();
});
it('ensures jobs have been imported', async () => {
if (testData.expected.jobType === 'anomaly-detector') {
await ml.navigation.navigateToStackManagementJobsListPageAnomalyDetectionTab();
await ml.jobTable.refreshJobList();
for (const id of testData.expected.jobIds) {
await ml.jobTable.filterWithSearchString(id);
}
for (const id of testData.expected.skippedJobIds) {
await ml.jobTable.filterWithSearchString(id, 0);
}
} else {
await ml.navigation.navigateToStackManagementJobsListPageAnalyticsTab();
await ml.dataFrameAnalyticsTable.refreshAnalyticsTable();
for (const id of testData.expected.jobIds) {
await ml.dataFrameAnalyticsTable.assertAnalyticsJobDisplayedInTable(id, true);
}
for (const id of testData.expected.skippedJobIds) {
await ml.dataFrameAnalyticsTable.assertAnalyticsJobDisplayedInTable(id, false);
}
}
});
}
describe('correctly fails to import bad data', async () => {
it('selects and reads file', async () => {
await ml.testExecution.logTestStep('selects job import');
await ml.stackManagementJobs.openImportFlyout();
await ml.stackManagementJobs.selectFileToImport(
path.join(__dirname, 'files_to_import', 'bad_data.json'),
true
);
});
});
});
}

View file

@ -13,5 +13,7 @@ export default function ({ loadTestFile }: FtrProviderContext) {
loadTestFile(require.resolve('./synchronize'));
loadTestFile(require.resolve('./manage_spaces'));
loadTestFile(require.resolve('./import_jobs'));
loadTestFile(require.resolve('./export_jobs'));
});
}

View file

@ -6,10 +6,16 @@
*/
import expect from '@kbn/expect';
import { REPO_ROOT } from '@kbn/utils';
import fs from 'fs';
import path from 'path';
import { FtrProviderContext } from '../../ftr_provider_context';
import { MlADJobTable } from './job_table';
import { MlDFAJobTable } from './data_frame_analytics_table';
import type { FtrProviderContext } from '../../ftr_provider_context';
import type { MlADJobTable } from './job_table';
import type { MlDFAJobTable } from './data_frame_analytics_table';
import type { JobType } from '../../../../plugins/ml/common/types/saved_objects';
import type { Job, Datafeed } from '../../../../plugins/ml/common/types/anomaly_detection_jobs';
import type { DataFrameAnalyticsConfig } from '../../../../plugins/ml/public/application/data_frame_analytics/common';
type SyncFlyoutObjectType =
| 'MissingObjects'
@ -18,7 +24,7 @@ type SyncFlyoutObjectType =
| 'ObjectsUnmatchedDatafeed';
export function MachineLearningStackManagementJobsProvider(
{ getService }: FtrProviderContext,
{ getService, getPageObjects }: FtrProviderContext,
mlADJobTable: MlADJobTable,
mlDFAJobTable: MlDFAJobTable
) {
@ -26,6 +32,9 @@ export function MachineLearningStackManagementJobsProvider(
const retry = getService('retry');
const testSubjects = getService('testSubjects');
const toasts = getService('toasts');
const log = getService('log');
const PageObjects = getPageObjects(['common']);
return {
async openSyncFlyout() {
@ -194,5 +203,212 @@ export function MachineLearningStackManagementJobsProvider(
}
await this.assertSpaceSelectionRowSelected(spaceId, shouldSelect);
},
async openImportFlyout() {
await retry.tryForTime(5000, async () => {
await testSubjects.click('mlJobsImportButton', 1000);
await testSubjects.existOrFail('mlJobMgmtImportJobsFlyout');
});
},
async openExportFlyout() {
await retry.tryForTime(5000, async () => {
await testSubjects.click('mlJobsExportButton', 1000);
await testSubjects.existOrFail('mlJobMgmtExportJobsFlyout');
});
},
async selectFileToImport(filePath: string, expectError: boolean = false) {
log.debug(`Importing file '${filePath}' ...`);
await PageObjects.common.setFileInputPath(filePath);
if (expectError) {
await testSubjects.existOrFail('~mlJobMgmtImportJobsFileReadErrorCallout');
} else {
await testSubjects.missingOrFail('~mlJobMgmtImportJobsFileReadErrorCallout');
await testSubjects.existOrFail('mlJobMgmtImportJobsFileRead');
}
},
async assertJobIdsExist(expectedJobIds: string[]) {
const inputs = await testSubjects.findAll('mlJobMgmtImportJobIdInput');
const actualJobIds = await Promise.all(inputs.map((i) => i.getAttribute('value')));
expect(actualJobIds.sort()).to.eql(
expectedJobIds.sort(),
`Expected job ids to be '${JSON.stringify(expectedJobIds)}' (got '${JSON.stringify(
actualJobIds
)}')`
);
},
async assertCorrectTitle(jobCount: number, jobType: JobType) {
const dataTestSubj =
jobType === 'anomaly-detector'
? 'mlJobMgmtImportJobsADTitle'
: 'mlJobMgmtImportJobsDFATitle';
const subj = await testSubjects.find(dataTestSubj);
const title = (await subj.parseDomContent()).html();
const jobTypeString =
jobType === 'anomaly-detector' ? 'anomaly detection' : 'data frame analytics';
const results = title.match(
/(\d) (anomaly detection|data frame analytics) job[s]? read from file$/
);
expect(results).to.not.eql(null, `Expected regex results to not be null`);
const foundCount = results![1];
const foundJobTypeString = results![2];
expect(foundCount).to.eql(
jobCount,
`Expected job count to be '${jobCount}' (got '${foundCount}')`
);
expect(foundJobTypeString).to.eql(
jobTypeString,
`Expected job count to be '${jobTypeString}' (got '${foundJobTypeString}')`
);
},
async assertJobIdsSkipped(expectedJobIds: string[]) {
const subj = await testSubjects.find('mlJobMgmtImportJobsCannotBeImportedCallout');
const skippedJobTitles = await subj.findAllByTagName('h5');
const actualJobIds = (
await Promise.all(skippedJobTitles.map((i) => i.parseDomContent()))
).map((t) => t.html());
expect(actualJobIds.sort()).to.eql(
expectedJobIds.sort(),
`Expected job ids to be '${JSON.stringify(expectedJobIds)}' (got '${JSON.stringify(
actualJobIds
)}')`
);
},
async importJobs() {
await testSubjects.click('mlJobMgmtImportImportButton', 1000);
await testSubjects.missingOrFail('mlJobMgmtImportJobsFlyout', { timeout: 60 * 1000 });
},
async assertReadErrorCalloutExists() {
await testSubjects.existOrFail('~mlJobMgmtImportJobsFileReadErrorCallout');
},
async selectExportJobType(jobType: JobType) {
if (jobType === 'anomaly-detector') {
await testSubjects.click('mlJobMgmtExportJobsADTab');
await testSubjects.existOrFail('mlJobMgmtExportJobsADJobList');
} else {
await testSubjects.click('mlJobMgmtExportJobsDFATab');
await testSubjects.existOrFail('mlJobMgmtExportJobsDFAJobList');
}
},
async selectExportJobSelectAll(jobType: JobType) {
await testSubjects.click('mlJobMgmtExportJobsSelectAllButton');
const subjLabel =
jobType === 'anomaly-detector'
? 'mlJobMgmtExportJobsADJobList'
: 'mlJobMgmtExportJobsDFAJobList';
const subj = await testSubjects.find(subjLabel);
const inputs = await subj.findAllByTagName('input');
const allInputValues = await Promise.all(inputs.map((input) => input.getAttribute('value')));
expect(allInputValues.every((i) => i === 'on')).to.eql(
true,
`Expected all inputs to be checked`
);
},
async getDownload(filePath: string) {
return retry.tryForTime(5000, async () => {
expect(fs.existsSync(filePath)).to.be(true);
return fs.readFileSync(filePath).toString();
});
},
getExportedFile(fileName: string) {
return path.resolve(REPO_ROOT, `target/functional-tests/downloads/${fileName}.json`);
},
deleteExportedFiles(fileNames: string[]) {
fileNames.forEach((file) => {
try {
fs.unlinkSync(this.getExportedFile(file));
} catch (e) {
// it might not have been there to begin with
}
});
},
async selectExportJobs() {
await testSubjects.click('mlJobMgmtExportExportButton');
await testSubjects.missingOrFail('mlJobMgmtExportJobsFlyout', { timeout: 60 * 1000 });
},
async assertExportedADJobsAreCorrect(expectedJobs: Array<{ job: Job; datafeed: Datafeed }>) {
const file = JSON.parse(
await this.getDownload(this.getExportedFile('anomaly_detection_jobs'))
);
const loadedFile = Array.isArray(file) ? file : [file];
const sortedActualJobs = loadedFile.sort((a, b) => a.job.job_id.localeCompare(b.job.job_id));
const sortedExpectedJobs = expectedJobs.sort((a, b) =>
a.job.job_id.localeCompare(b.job.job_id)
);
expect(sortedActualJobs.length).to.eql(
sortedExpectedJobs.length,
`Expected length of exported jobs to be '${sortedExpectedJobs.length}' (got '${sortedActualJobs.length}')`
);
sortedExpectedJobs.forEach((expectedJob, i) => {
expect(sortedActualJobs[i].job.job_id).to.eql(
expectedJob.job.job_id,
`Expected job id to be '${expectedJob.job.job_id}' (got '${sortedActualJobs[i].job.job_id}')`
);
expect(sortedActualJobs[i].job.analysis_config.detectors.length).to.eql(
expectedJob.job.analysis_config.detectors.length,
`Expected detectors length to be '${expectedJob.job.analysis_config.detectors.length}' (got '${sortedActualJobs[i].job.analysis_config.detectors.length}')`
);
expect(sortedActualJobs[i].job.analysis_config.detectors[0].function).to.eql(
expectedJob.job.analysis_config.detectors[0].function,
`Expected first detector function to be '${expectedJob.job.analysis_config.detectors[0].function}' (got '${sortedActualJobs[i].job.analysis_config.detectors[0].function}')`
);
expect(sortedActualJobs[i].datafeed.datafeed_id).to.eql(
expectedJob.datafeed.datafeed_id,
`Expected job id to be '${expectedJob.datafeed.datafeed_id}' (got '${sortedActualJobs[i].datafeed.datafeed_id}')`
);
});
},
async assertExportedDFAJobsAreCorrect(expectedJobs: DataFrameAnalyticsConfig[]) {
const file = JSON.parse(
await this.getDownload(this.getExportedFile('data_frame_analytics_jobs'))
);
const loadedFile = Array.isArray(file) ? file : [file];
const sortedActualJobs = loadedFile.sort((a, b) => a.id.localeCompare(b.id));
const sortedExpectedJobs = expectedJobs.sort((a, b) => a.id.localeCompare(b.id));
expect(sortedActualJobs.length).to.eql(
sortedExpectedJobs.length,
`Expected length of exported jobs to be '${sortedExpectedJobs.length}' (got '${sortedActualJobs.length}')`
);
sortedExpectedJobs.forEach((expectedJob, i) => {
expect(sortedActualJobs[i].id).to.eql(
expectedJob.id,
`Expected job id to be '${expectedJob.id}' (got '${sortedActualJobs[i].id}')`
);
const expectedType = Object.keys(expectedJob.analysis)[0];
const actualType = Object.keys(sortedActualJobs[i].analysis)[0];
expect(actualType).to.eql(
expectedType,
`Expected job type to be '${expectedType}' (got '${actualType}')`
);
expect(sortedActualJobs[i].dest.index).to.eql(
expectedJob.dest.index,
`Expected destination index to be '${expectedJob.dest.index}' (got '${sortedActualJobs[i].dest.index}')`
);
});
},
};
}