open-nomad/ui/tests/acceptance/optimize-test.js

655 lines
21 KiB
JavaScript
Raw Normal View History

import { module, test } from 'qunit';
import { setupApplicationTest } from 'ember-qunit';
import { currentURL, visit } from '@ember/test-helpers';
import { setupMirage } from 'ember-cli-mirage/test-support';
import a11yAudit from 'nomad-ui/tests/helpers/a11y-audit';
import Response from 'ember-cli-mirage/response';
import moment from 'moment';
import Optimize from 'nomad-ui/tests/pages/optimize';
import PageLayout from 'nomad-ui/tests/pages/layout';
import JobsList from 'nomad-ui/tests/pages/jobs/list';
let managementToken, clientToken;
function getLatestRecommendationSubmitTimeForJob(job) {
const tasks = job.taskGroups.models
.mapBy('tasks.models')
.reduce((tasks, taskModels) => tasks.concat(taskModels), []);
const recommendations = tasks.reduce(
(recommendations, task) => recommendations.concat(task.recommendations.models),
[]
);
return Math.max(...recommendations.mapBy('submitTime'));
}
module('Acceptance | optimize', function(hooks) {
setupApplicationTest(hooks);
setupMirage(hooks);
hooks.beforeEach(async function() {
server.create('node');
server.createList('namespace', 2);
const jobs = server.createList('job', 2, {
createRecommendations: true,
groupsCount: 1,
groupTaskCount: 2,
namespaceId: server.db.namespaces[1].id,
});
jobs.sort((jobA, jobB) => {
return (
getLatestRecommendationSubmitTimeForJob(jobB) -
getLatestRecommendationSubmitTimeForJob(jobA)
);
});
[this.job1, this.job2] = jobs;
managementToken = server.create('token');
clientToken = server.create('token');
window.localStorage.clear();
window.localStorage.nomadTokenSecret = managementToken.secretId;
});
test('it passes an accessibility audit', async function(assert) {
await Optimize.visit();
await a11yAudit(assert);
});
test('lets recommendations be toggled, reports the choices to the recommendations API, and displays task group recommendations serially', async function(assert) {
await Optimize.visit();
const currentTaskGroup = this.job1.taskGroups.models[0];
const nextTaskGroup = this.job2.taskGroups.models[0];
assert.equal(Optimize.breadcrumbFor('optimize').text, 'Recommendations');
assert.equal(
Optimize.recommendationSummaries[0].slug,
`${this.job1.name} / ${currentTaskGroup.name}`
);
assert.equal(
Optimize.breadcrumbFor('optimize.summary').text,
`${this.job1.name} / ${currentTaskGroup.name}`
);
assert.equal(Optimize.recommendationSummaries[0].namespace, this.job1.namespace);
assert.equal(
Optimize.recommendationSummaries[1].slug,
`${this.job2.name} / ${nextTaskGroup.name}`
);
const currentRecommendations = currentTaskGroup.tasks.models.reduce(
(recommendations, task) => recommendations.concat(task.recommendations.models),
[]
);
const latestSubmitTime = Math.max(...currentRecommendations.mapBy('submitTime'));
Optimize.recommendationSummaries[0].as(summary => {
assert.equal(
summary.date,
moment(new Date(latestSubmitTime / 1000000)).format('MMM DD HH:mm:ss ZZ')
);
const currentTaskGroupAllocations = server.schema.allocations.where({
jobId: currentTaskGroup.job.name,
taskGroup: currentTaskGroup.name,
});
assert.equal(summary.allocationCount, currentTaskGroupAllocations.length);
const { currCpu, currMem } = currentTaskGroup.tasks.models.reduce(
(currentResources, task) => {
currentResources.currCpu += task.resources.CPU;
currentResources.currMem += task.resources.MemoryMB;
return currentResources;
},
{ currCpu: 0, currMem: 0 }
);
const { recCpu, recMem } = currentRecommendations.reduce(
(recommendedResources, recommendation) => {
if (recommendation.resource === 'CPU') {
recommendedResources.recCpu += recommendation.value;
} else {
recommendedResources.recMem += recommendation.value;
}
return recommendedResources;
},
{ recCpu: 0, recMem: 0 }
);
const cpuDiff = recCpu > 0 ? recCpu - currCpu : 0;
const memDiff = recMem > 0 ? recMem - currMem : 0;
const cpuSign = cpuDiff > 0 ? '+' : '';
const memSign = memDiff > 0 ? '+' : '';
const cpuDiffPercent = Math.round((100 * cpuDiff) / currCpu);
const memDiffPercent = Math.round((100 * memDiff) / currMem);
assert.equal(
summary.cpu,
cpuDiff ? `${cpuSign}${cpuDiff} MHz ${cpuSign}${cpuDiffPercent}%` : ''
);
assert.equal(
summary.memory,
memDiff ? `${memSign}${formattedMemDiff(memDiff)} ${memSign}${memDiffPercent}%` : ''
);
assert.equal(
summary.aggregateCpu,
cpuDiff ? `${cpuSign}${cpuDiff * currentTaskGroupAllocations.length} MHz` : ''
);
assert.equal(
summary.aggregateMemory,
memDiff ? `${memSign}${formattedMemDiff(memDiff * currentTaskGroupAllocations.length)}` : ''
);
});
assert.ok(Optimize.recommendationSummaries[0].isActive);
assert.notOk(Optimize.recommendationSummaries[1].isActive);
assert.equal(Optimize.card.slug.jobName, this.job1.name);
assert.equal(Optimize.card.slug.groupName, currentTaskGroup.name);
const summaryMemoryBefore = Optimize.recommendationSummaries[0].memory;
let toggledAnything = true;
// Toggle off all memory
if (Optimize.card.togglesTable.toggleAllMemory.isPresent) {
await Optimize.card.togglesTable.toggleAllMemory.toggle();
assert.notOk(Optimize.card.togglesTable.tasks[0].memory.isActive);
assert.notOk(Optimize.card.togglesTable.tasks[1].memory.isActive);
} else if (!Optimize.card.togglesTable.tasks[0].cpu.isDisabled) {
await Optimize.card.togglesTable.tasks[0].memory.toggle();
} else {
toggledAnything = false;
}
assert.equal(
Optimize.recommendationSummaries[0].memory,
summaryMemoryBefore,
'toggling recommendations doesnt affect the summary table diffs'
);
const currentTaskIds = currentTaskGroup.tasks.models.mapBy('id');
const taskIdFilter = task => currentTaskIds.includes(task.taskId);
const cpuRecommendationIds = server.schema.recommendations
.where({ resource: 'CPU' })
.models.filter(taskIdFilter)
.mapBy('id');
const memoryRecommendationIds = server.schema.recommendations
.where({ resource: 'MemoryMB' })
.models.filter(taskIdFilter)
.mapBy('id');
const appliedIds = toggledAnything ? cpuRecommendationIds : memoryRecommendationIds;
const dismissedIds = toggledAnything ? memoryRecommendationIds : [];
await Optimize.card.acceptButton.click();
const request = server.pretender.handledRequests.filterBy('method', 'POST').pop();
const { Apply, Dismiss } = JSON.parse(request.requestBody);
assert.equal(request.url, '/v1/recommendations/apply');
assert.deepEqual(Apply, appliedIds);
assert.deepEqual(Dismiss, dismissedIds);
assert.equal(Optimize.card.slug.jobName, this.job2.name);
assert.equal(Optimize.card.slug.groupName, nextTaskGroup.name);
assert.ok(Optimize.recommendationSummaries[1].isActive);
});
test('can navigate between summaries via the table', async function(assert) {
server.createList('job', 10, {
createRecommendations: true,
groupsCount: 1,
groupTaskCount: 2,
namespaceId: server.db.namespaces[1].id,
});
await Optimize.visit();
await Optimize.recommendationSummaries[1].click();
assert.equal(
`${Optimize.card.slug.jobName} / ${Optimize.card.slug.groupName}`,
Optimize.recommendationSummaries[1].slug
);
assert.ok(Optimize.recommendationSummaries[1].isActive);
});
test('can visit a summary directly via URL', async function(assert) {
server.createList('job', 10, {
createRecommendations: true,
groupsCount: 1,
groupTaskCount: 2,
namespaceId: server.db.namespaces[1].id,
});
await Optimize.visit();
const lastSummary =
Optimize.recommendationSummaries[Optimize.recommendationSummaries.length - 1];
const collapsedSlug = lastSummary.slug.replace(' / ', '/');
// preferable to use page objects visitable but it encodes the slash
await visit(`/optimize/${collapsedSlug}?namespace=${lastSummary.namespace}`);
assert.equal(
`${Optimize.card.slug.jobName} / ${Optimize.card.slug.groupName}`,
lastSummary.slug
);
assert.ok(lastSummary.isActive);
assert.equal(currentURL(), `/optimize/${collapsedSlug}?namespace=${lastSummary.namespace}`);
});
test('when a summary is not found, an error message is shown, but the URL persists', async function(assert) {
await visit('/optimize/nonexistent/summary?namespace=anamespace');
assert.equal(currentURL(), '/optimize/nonexistent/summary?namespace=anamespace');
assert.ok(Optimize.applicationError.isPresent);
assert.equal(Optimize.applicationError.title, 'Not Found');
});
test('cannot return to already-processed summaries', async function(assert) {
await Optimize.visit();
await Optimize.card.acceptButton.click();
assert.ok(Optimize.recommendationSummaries[0].isDisabled);
await Optimize.recommendationSummaries[0].click();
assert.ok(Optimize.recommendationSummaries[1].isActive);
});
test('can dismiss a set of recommendations', async function(assert) {
await Optimize.visit();
const currentTaskGroup = this.job1.taskGroups.models[0];
const currentTaskIds = currentTaskGroup.tasks.models.mapBy('id');
const taskIdFilter = task => currentTaskIds.includes(task.taskId);
const idsBeforeDismissal = server.schema.recommendations
.all()
.models.filter(taskIdFilter)
.mapBy('id');
await Optimize.card.dismissButton.click();
const request = server.pretender.handledRequests.filterBy('method', 'POST').pop();
const { Apply, Dismiss } = JSON.parse(request.requestBody);
assert.equal(request.url, '/v1/recommendations/apply');
assert.deepEqual(Apply, []);
assert.deepEqual(Dismiss, idsBeforeDismissal);
});
test('it displays an error encountered trying to save and proceeds to the next summary when the error is dismissed', async function(assert) {
server.post('/recommendations/apply', function() {
return new Response(500, {}, null);
});
await Optimize.visit();
await Optimize.card.acceptButton.click();
assert.ok(Optimize.error.isPresent);
assert.equal(Optimize.error.headline, 'Recommendation error');
assert.equal(
Optimize.error.errors,
'Error: Ember Data Request POST /v1/recommendations/apply returned a 500 Payload (application/json)'
);
await Optimize.error.dismiss();
assert.equal(Optimize.card.slug.jobName, this.job2.name);
});
test('it displays an empty message when there are no recommendations', async function(assert) {
server.db.recommendations.remove();
await Optimize.visit();
assert.ok(Optimize.empty.isPresent);
assert.equal(Optimize.empty.headline, 'No Recommendations');
});
test('it displays an empty message after all recommendations have been processed', async function(assert) {
await Optimize.visit();
await Optimize.card.acceptButton.click();
await Optimize.card.acceptButton.click();
assert.ok(Optimize.empty.isPresent);
});
test('it redirects to jobs and hides the gutter link when the token lacks permissions', async function(assert) {
window.localStorage.nomadTokenSecret = clientToken.secretId;
await Optimize.visit();
assert.equal(currentURL(), '/jobs');
assert.ok(PageLayout.gutter.optimize.isHidden);
});
test('it reloads partially-loaded jobs', async function(assert) {
await JobsList.visit();
await Optimize.visit();
assert.equal(Optimize.recommendationSummaries.length, 2);
});
});
module('Acceptance | optimize search and facets', function(hooks) {
setupApplicationTest(hooks);
setupMirage(hooks);
hooks.beforeEach(async function() {
server.create('node');
server.createList('namespace', 2);
managementToken = server.create('token');
window.localStorage.clear();
window.localStorage.nomadTokenSecret = managementToken.secretId;
});
test('search field narrows summary table results, changes the active summary if it no longer matches, and displays a no matches message when there are none', async function(assert) {
server.createList('job', 1, {
name: 'zzzzzz',
createRecommendations: true,
groupsCount: 1,
groupTaskCount: 6,
});
// Ensure this jobs recommendations are sorted to the top of the table
const futureSubmitTime = (Date.now() + 10000) * 1000000;
server.db.recommendations.update({ submitTime: futureSubmitTime });
server.createList('job', 1, {
name: 'oooooo',
createRecommendations: true,
groupsCount: 2,
groupTaskCount: 4,
});
server.createList('job', 1, {
name: 'pppppp',
createRecommendations: true,
groupsCount: 2,
groupTaskCount: 4,
});
await Optimize.visit();
assert.equal(Optimize.card.slug.jobName, 'zzzzzz');
2020-11-09 15:32:49 +00:00
assert.equal(Optimize.search.placeholder, `Search ${Optimize.recommendationSummaries.length} recommendations...`);
await Optimize.search.fillIn('ooo');
assert.equal(Optimize.recommendationSummaries.length, 2);
assert.ok(Optimize.recommendationSummaries[0].slug.startsWith('oooooo'));
2020-11-09 15:41:18 +00:00
assert.equal(Optimize.card.slug.jobName, 'oooooo');
assert.ok(currentURL().includes('oooooo'));
2020-11-09 15:41:18 +00:00
await Optimize.search.fillIn('qqq');
assert.notOk(Optimize.card.isPresent);
assert.ok(Optimize.empty.isPresent);
assert.equal(Optimize.empty.headline, 'No Matches');
});
test('the optimize page has appropriate faceted search options', async function(assert) {
server.createList('job', 4, {
status: 'running',
createRecommendations: true,
childrenCount: 0,
});
await Optimize.visit();
assert.ok(Optimize.facets.type.isPresent, 'Type facet found');
assert.ok(Optimize.facets.status.isPresent, 'Status facet found');
assert.ok(Optimize.facets.datacenter.isPresent, 'Datacenter facet found');
assert.ok(Optimize.facets.prefix.isPresent, 'Prefix facet found');
});
testFacet('Type', {
facet: Optimize.facets.type,
paramName: 'type',
expectedOptions: ['Service', 'System'],
async beforeEach() {
server.createList('job', 2, {
type: 'service',
createRecommendations: true,
groupsCount: 1,
groupTaskCount: 2,
});
server.createList('job', 2, {
type: 'system',
createRecommendations: true,
groupsCount: 1,
groupTaskCount: 2,
});
await Optimize.visit();
},
filter(taskGroup, selection) {
let displayType = taskGroup.job.type;
return selection.includes(displayType);
},
});
testFacet('Status', {
facet: Optimize.facets.status,
paramName: 'status',
expectedOptions: ['Pending', 'Running', 'Dead'],
async beforeEach() {
server.createList('job', 2, {
status: 'pending',
createRecommendations: true,
groupsCount: 1,
groupTaskCount: 2,
childrenCount: 0,
});
server.createList('job', 2, {
status: 'running',
createRecommendations: true,
groupsCount: 1,
groupTaskCount: 2,
childrenCount: 0,
});
server.createList('job', 2, { status: 'dead', createRecommendations: true, childrenCount: 0 });
await Optimize.visit();
},
filter: (taskGroup, selection) => selection.includes(taskGroup.job.status),
});
testFacet('Datacenter', {
facet: Optimize.facets.datacenter,
paramName: 'dc',
expectedOptions(jobs) {
const allDatacenters = new Set(
jobs.mapBy('datacenters').reduce((acc, val) => acc.concat(val), [])
);
return Array.from(allDatacenters).sort();
},
async beforeEach() {
server.create('job', {
datacenters: ['pdx', 'lax'],
createRecommendations: true,
groupsCount: 1,
groupTaskCount: 2,
childrenCount: 0,
});
server.create('job', {
datacenters: ['pdx', 'ord'],
createRecommendations: true,
groupsCount: 1,
groupTaskCount: 2,
childrenCount: 0,
});
server.create('job', {
datacenters: ['lax', 'jfk'],
createRecommendations: true,
groupsCount: 1,
groupTaskCount: 2,
childrenCount: 0,
});
server.create('job', {
datacenters: ['jfk', 'dfw'],
createRecommendations: true,
groupsCount: 1,
groupTaskCount: 2,
childrenCount: 0,
});
server.create('job', { datacenters: ['pdx'], createRecommendations: true, childrenCount: 0 });
await Optimize.visit();
},
filter: (taskGroup, selection) => taskGroup.job.datacenters.find(dc => selection.includes(dc)),
});
testFacet('Prefix', {
facet: Optimize.facets.prefix,
paramName: 'prefix',
expectedOptions: ['hashi (3)', 'nmd (2)', 'pre (2)'],
async beforeEach() {
[
'pre-one',
'hashi_one',
'nmd.one',
'one-alone',
'pre_two',
'hashi.two',
'hashi-three',
'nmd_two',
'noprefix',
].forEach(name => {
server.create('job', {
name,
createRecommendations: true,
createAllocations: true,
groupsCount: 1,
groupTaskCount: 2,
childrenCount: 0,
});
});
await Optimize.visit();
},
filter: (taskGroup, selection) => selection.find(prefix => taskGroup.job.name.startsWith(prefix)),
});
function testFacet(label, { facet, paramName, beforeEach, filter, expectedOptions }) {
test(`the ${label} facet has the correct options`, async function(assert) {
await beforeEach();
await facet.toggle();
let expectation;
if (typeof expectedOptions === 'function') {
expectation = expectedOptions(server.db.jobs);
} else {
expectation = expectedOptions;
}
assert.deepEqual(
facet.options.map(option => option.label.trim()),
expectation,
'Options for facet are as expected'
);
});
test(`the ${label} facet filters the recommendation summaries by ${label}`, async function(assert) {
let option;
await beforeEach();
await facet.toggle();
option = facet.options.objectAt(0);
await option.toggle();
const selection = [option.key];
const sortedRecommendations = server.db.recommendations
.sortBy('submitTime').reverse();
const recommendationTaskGroups = server.schema.tasks.find(sortedRecommendations.mapBy('taskId').uniq()).models.mapBy('taskGroup').uniqBy('id').filter(group => filter(group, selection));
Optimize.recommendationSummaries.forEach((summary, index) => {
const group = recommendationTaskGroups[index];
assert.equal(summary.slug, `${group.job.name} / ${group.name}`);
});
});
test(`selecting multiple options in the ${label} facet results in a broader search`, async function(assert) {
const selection = [];
await beforeEach();
await facet.toggle();
const option1 = facet.options.objectAt(0);
const option2 = facet.options.objectAt(1);
await option1.toggle();
selection.push(option1.key);
await option2.toggle();
selection.push(option2.key);
const sortedRecommendations = server.db.recommendations
.sortBy('submitTime').reverse();
const recommendationTaskGroups = server.schema.tasks.find(sortedRecommendations.mapBy('taskId').uniq()).models.mapBy('taskGroup').uniqBy('id').filter(group => filter(group, selection));
Optimize.recommendationSummaries.forEach((summary, index) => {
const group = recommendationTaskGroups[index];
assert.equal(summary.slug, `${group.job.name} / ${group.name}`);
});
});
test(`selecting options in the ${label} facet updates the ${paramName} query param`, async function(assert) {
const selection = [];
await beforeEach();
await facet.toggle();
const option1 = facet.options.objectAt(0);
const option2 = facet.options.objectAt(1);
await option1.toggle();
selection.push(option1.key);
await option2.toggle();
selection.push(option2.key);
assert.ok(currentURL().includes(encodeURIComponent(JSON.stringify(selection))));
});
}
});
function formattedMemDiff(memDiff) {
const absMemDiff = Math.abs(memDiff);
const negativeSign = memDiff < 0 ? '-' : '';
if (absMemDiff >= 1024) {
const gibDiff = absMemDiff / 1024;
if (Number.isInteger(gibDiff)) {
return `${negativeSign}${gibDiff} GiB`;
} else {
return `${negativeSign}${gibDiff.toFixed(2)} GiB`;
}
} else {
return `${negativeSign}${absMemDiff} MiB`;
}
}